@rxdi/forms 0.7.213 → 0.7.214
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 +191 -496
- package/dist/form.array.js +1 -0
- package/dist/form.decorator.js +5 -1
- package/dist/form.group.d.ts +9 -8
- package/dist/form.group.js +34 -4
- package/dist/form.tokens.d.ts +14 -0
- package/package.json +25 -25
package/README.md
CHANGED
|
@@ -1,595 +1,290 @@
|
|
|
1
|
-
# Reactive
|
|
1
|
+
# Reactive Forms for LitHtml (Enhanced)
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
```bash
|
|
5
|
-
npm i @rxdi/forms
|
|
6
|
-
```
|
|
3
|
+
A lightweight, strongly-typed, reactive forms library for LitHtml applications.
|
|
7
4
|
|
|
5
|
+
## Features
|
|
8
6
|
|
|
7
|
+
- **Strict Typing**: Full TypeScript support with `UnwrapValue` and `NestedKeyOf` for deep property inference.
|
|
8
|
+
- **Nested Forms**: Support for deep `FormGroup` nesting and `FormArray`.
|
|
9
|
+
- **Automatic Binding**: Bind component models directly to forms with `@Form({ model: 'myModel' })`.
|
|
10
|
+
- **Reactive**: based on `rxjs` `BehaviorSubject` for value streams.
|
|
11
|
+
- **Recursive Updates**: `patchValue` updates deep structures recursively.
|
|
9
12
|
|
|
10
|
-
|
|
13
|
+
## Installation
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
```bash
|
|
16
|
+
npm i @rxdi/forms
|
|
17
|
+
```
|
|
15
18
|
|
|
16
|
-
|
|
19
|
+
## Basic Usage
|
|
17
20
|
|
|
21
|
+
### 1. Define Model & Component
|
|
18
22
|
|
|
19
23
|
```typescript
|
|
20
|
-
import { html, Component } from '@rxdi/lit-html';
|
|
21
|
-
import {
|
|
22
|
-
|
|
24
|
+
import { html, Component, LitElement } from '@rxdi/lit-html';
|
|
25
|
+
import { Form, FormGroup } from '@rxdi/forms';
|
|
26
|
+
|
|
27
|
+
interface UserParams {
|
|
28
|
+
firstName: string;
|
|
29
|
+
address: {
|
|
30
|
+
city: string;
|
|
31
|
+
street: string;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
23
34
|
|
|
24
|
-
/**
|
|
25
|
-
* @customElement login-component
|
|
26
|
-
*/
|
|
27
35
|
@Component({
|
|
28
|
-
selector: '
|
|
29
|
-
template(this:
|
|
36
|
+
selector: 'user-profile',
|
|
37
|
+
template(this: UserProfile) {
|
|
30
38
|
return html`
|
|
31
|
-
<form name="
|
|
39
|
+
<form name="user-form" @submit=${this.onSubmit}>
|
|
40
|
+
|
|
41
|
+
<!-- Deep Binding with Dot Notation -->
|
|
32
42
|
<input
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
value=${this.form.value.email}
|
|
37
|
-
placeholder="Email address"
|
|
38
|
-
required
|
|
39
|
-
autofocus
|
|
43
|
+
name="firstName"
|
|
44
|
+
.value=${this.form.value.firstName}
|
|
45
|
+
@blur=${() => this.requestUpdate()}
|
|
40
46
|
/>
|
|
47
|
+
|
|
48
|
+
<!-- Nested Group Binding -->
|
|
41
49
|
<input
|
|
42
|
-
|
|
43
|
-
value=${this.form.value.
|
|
44
|
-
|
|
45
|
-
placeholder="Password"
|
|
46
|
-
required=""
|
|
50
|
+
name="address.city"
|
|
51
|
+
.value=${this.form.value.address.city}
|
|
52
|
+
@blur=${() => this.requestUpdate()}
|
|
47
53
|
/>
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
<input name="rememberMe" type="checkbox" /> Remember me
|
|
51
|
-
</label>
|
|
52
|
-
</div>
|
|
53
|
-
<button type="submit">
|
|
54
|
-
Sign in
|
|
55
|
-
</button>
|
|
54
|
+
|
|
55
|
+
<button type="submit">Save</button>
|
|
56
56
|
</form>
|
|
57
57
|
`;
|
|
58
58
|
}
|
|
59
59
|
})
|
|
60
|
-
export class
|
|
60
|
+
export class UserProfile extends LitElement {
|
|
61
|
+
|
|
62
|
+
// Model to bind
|
|
63
|
+
@property({ type: Object })
|
|
64
|
+
user: UserParams = {
|
|
65
|
+
firstName: 'John',
|
|
66
|
+
address: { city: 'New York', street: '5th Ave' }
|
|
67
|
+
};
|
|
68
|
+
|
|
61
69
|
@Form({
|
|
70
|
+
name: 'user-form',
|
|
62
71
|
strategy: 'change',
|
|
63
|
-
|
|
72
|
+
model: 'user' // Automatic Model Binding!
|
|
64
73
|
})
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
74
|
+
form = new FormGroup({
|
|
75
|
+
firstName: '',
|
|
76
|
+
address: new FormGroup({
|
|
77
|
+
city: '',
|
|
78
|
+
street: ''
|
|
79
|
+
})
|
|
69
80
|
});
|
|
70
81
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
this.form.getValue('password');
|
|
76
|
-
this.form.setValue('email', 'blabla');
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
onSubmit(event: Event) {
|
|
80
|
-
this.form.values;
|
|
82
|
+
onSubmit(e: Event) {
|
|
83
|
+
e.preventDefault();
|
|
84
|
+
console.log(this.form.value);
|
|
85
|
+
// Output: { firstName: 'John', address: { city: 'New York', street: '5th Ave' } }
|
|
81
86
|
}
|
|
82
87
|
}
|
|
83
|
-
|
|
84
88
|
```
|
|
85
89
|
|
|
90
|
+
## New Features
|
|
86
91
|
|
|
87
|
-
|
|
88
|
-
|
|
92
|
+
### Automatic Model Binding
|
|
93
|
+
Use the `model` property in the `@Form` decorator to automatically populate the form from a component property.
|
|
89
94
|
|
|
90
95
|
```typescript
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* @customElement login-component
|
|
97
|
-
*/
|
|
98
|
-
@Component({
|
|
99
|
-
selector: 'login-component',
|
|
100
|
-
template(this: LoginComponent) {
|
|
101
|
-
return html`
|
|
102
|
-
<form name="my-form" @submit=${this.onSubmit}>
|
|
103
|
-
<input
|
|
104
|
-
style="margin-bottom: 20px;"
|
|
105
|
-
name="email"
|
|
106
|
-
type="email"
|
|
107
|
-
placeholder="Email address"
|
|
108
|
-
required
|
|
109
|
-
autofocus
|
|
110
|
-
/>
|
|
111
|
-
${this.form.hasError('email', 'blabla') ? html`${this.form.getError('email', 'blabla')}` : ''}
|
|
112
|
-
<input
|
|
113
|
-
type="password"
|
|
114
|
-
name="password"
|
|
115
|
-
placeholder="Password"
|
|
116
|
-
required=""
|
|
117
|
-
/>
|
|
118
|
-
<div>
|
|
119
|
-
<label>
|
|
120
|
-
<input name="rememberMe" type="checkbox" /> Remember me
|
|
121
|
-
</label>
|
|
122
|
-
</div>
|
|
123
|
-
<button type="submit">
|
|
124
|
-
Sign in
|
|
125
|
-
</button>
|
|
126
|
-
</form>
|
|
127
|
-
`;
|
|
128
|
-
}
|
|
96
|
+
@Form({
|
|
97
|
+
name: 'my-form',
|
|
98
|
+
model: 'myData' // matches this.myData
|
|
129
99
|
})
|
|
130
|
-
|
|
131
|
-
@Form({
|
|
132
|
-
strategy: 'change',
|
|
133
|
-
name: 'my-form'
|
|
134
|
-
})
|
|
135
|
-
private form = new FormGroup({
|
|
136
|
-
password: '',
|
|
137
|
-
email: ['', [this.validateEmail]],
|
|
138
|
-
rememberMe: ''
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
OnUpdate() {
|
|
142
|
-
this.form.getValue('password');
|
|
143
|
-
this.form.setValue('email', 'blabla');
|
|
144
|
-
|
|
145
|
-
this.form.get('password'); // returns HTMLIntputElement
|
|
146
|
-
this.form.hasError('email', 'blabla')
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
onSubmit(event: Event) {
|
|
150
|
-
this.form.values;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
validateEmail(element: HTMLInputElement) {
|
|
154
|
-
if (element.value === 'restrictedEmail@gmail.com') {
|
|
155
|
-
return { key: 'blabla', message: 'Please specify different email'};
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
100
|
+
form = new FormGroup({ ... });
|
|
160
101
|
```
|
|
102
|
+
The library reads `this.myData` during initialization and calls `form.patchValue(this.myData)`.
|
|
161
103
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
#### Group multiple inputs with single check intaraction
|
|
165
|
-
|
|
166
|
-
> By default all inputs with same attribute `name` are binded together,
|
|
104
|
+
### Nested FormGroups & FormArray
|
|
105
|
+
You can nest `FormGroup`s arbitrarily deep.
|
|
167
106
|
|
|
168
107
|
```typescript
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
108
|
+
form = new FormGroup({
|
|
109
|
+
meta: new FormGroup({
|
|
110
|
+
id: 1,
|
|
111
|
+
flags: new FormGroup({
|
|
112
|
+
isActive: true,
|
|
113
|
+
isAdmin: false
|
|
114
|
+
})
|
|
115
|
+
}),
|
|
116
|
+
tags: new FormArray([
|
|
117
|
+
new FormGroup({ label: 'red' })
|
|
118
|
+
])
|
|
119
|
+
});
|
|
176
120
|
```
|
|
177
121
|
|
|
122
|
+
**Template Binding:**
|
|
123
|
+
Use dot notation for nested controls:
|
|
178
124
|
```html
|
|
179
|
-
<
|
|
180
|
-
<input
|
|
181
|
-
name="condition"
|
|
182
|
-
type="checkbox"
|
|
183
|
-
value='none'
|
|
184
|
-
/>
|
|
185
|
-
None
|
|
186
|
-
</label>
|
|
187
|
-
|
|
188
|
-
<label>
|
|
189
|
-
<input
|
|
190
|
-
name="condition"
|
|
191
|
-
type="checkbox"
|
|
192
|
-
value='checked'
|
|
193
|
-
/>
|
|
194
|
-
Checked
|
|
195
|
-
</label>
|
|
196
|
-
|
|
197
|
-
<label>
|
|
198
|
-
<input
|
|
199
|
-
name="condition"
|
|
200
|
-
type="checkbox"
|
|
201
|
-
value='not-checked'
|
|
202
|
-
/>
|
|
203
|
-
Not checked
|
|
204
|
-
</label>
|
|
125
|
+
<input name="meta.flags.isActive" type="checkbox" />
|
|
205
126
|
```
|
|
206
127
|
|
|
128
|
+
### Type Safety & Autosuggestion
|
|
129
|
+
The library now extensively uses advanced TypeScript features:
|
|
130
|
+
- **`form.value`**: Returns the unwrapped pure object type (e.g., `{ meta: { flags: { isActive: boolean } } }`).
|
|
131
|
+
- **`form.get('path.to.prop')`**: Provides autocomplete for deep paths!
|
|
132
|
+
```typescript
|
|
133
|
+
// TypeScript knows this is valid:
|
|
134
|
+
this.form.get('meta.flags.isActive');
|
|
207
135
|
|
|
208
|
-
|
|
209
|
-
|
|
136
|
+
// And this is invalid:
|
|
137
|
+
this.form.get('meta.flags.wrongProp'); // Error!
|
|
138
|
+
```
|
|
210
139
|
|
|
211
|
-
|
|
140
|
+
### Recursive PatchValue
|
|
141
|
+
Update multiple fields deeply at once:
|
|
212
142
|
|
|
213
143
|
```typescript
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
144
|
+
this.form.patchValue({
|
|
145
|
+
meta: {
|
|
146
|
+
flags: {
|
|
147
|
+
isActive: false
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
// Only updates 'isActive', leaves other fields untouched.
|
|
222
152
|
```
|
|
223
153
|
|
|
154
|
+
## API Reference
|
|
224
155
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
By default this library uses native error messages provided by HTML5 form validation API
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
You can create your error template as follow:
|
|
156
|
+
### Validators
|
|
157
|
+
Validators are async functions returning `InputErrorMessage` or `void`.
|
|
232
158
|
|
|
233
159
|
```typescript
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
// Check 'touched' to show errors only after interaction
|
|
238
|
-
// Check 'validity.valid' silently to avoid side effects
|
|
239
|
-
if (input && input.touched && !input.validity.valid) {
|
|
240
|
-
return html`
|
|
241
|
-
<div style="color:red; font-size: 13px;">${input.validationMessage}</div>
|
|
242
|
-
`;
|
|
160
|
+
export function CustomValidator(element: AbstractInput) {
|
|
161
|
+
if (element.value === 'invalid') {
|
|
162
|
+
return { key: 'customError', message: 'Value is invalid' };
|
|
243
163
|
}
|
|
244
|
-
return '';
|
|
245
164
|
}
|
|
246
|
-
```
|
|
247
165
|
|
|
248
|
-
|
|
166
|
+
// Usage
|
|
167
|
+
new FormGroup({
|
|
168
|
+
field: ['', [CustomValidator]]
|
|
169
|
+
})
|
|
249
170
|
```
|
|
250
171
|
|
|
172
|
+
### Error Display Information
|
|
173
|
+
Use the `touched` and `validity.valid` properties for clean UI.
|
|
251
174
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
value=${this.form.value.email}
|
|
260
|
-
class="form-control"
|
|
261
|
-
placeholder="Email address"
|
|
262
|
-
required
|
|
263
|
-
autofocus
|
|
264
|
-
/>
|
|
265
|
-
${InputErrorTemplate(this.form.get('email'))}
|
|
266
|
-
</form>
|
|
175
|
+
```typescript
|
|
176
|
+
function ErrorTemplate(input: AbstractInput) {
|
|
177
|
+
if (input?.touched && !input.validity.valid) {
|
|
178
|
+
return html`<div class="error">${input.validationMessage}</div>`;
|
|
179
|
+
}
|
|
180
|
+
return html``;
|
|
181
|
+
}
|
|
267
182
|
```
|
|
268
183
|
|
|
184
|
+
## Advanced Usage
|
|
269
185
|
|
|
186
|
+
### 1. Grouping Multiple Inputs (Checkbox Groups)
|
|
270
187
|
|
|
188
|
+
By default, inputs with the same `name` attribute are treated as a single value (last write wins). However, for checkboxes, you often want an array of values.
|
|
271
189
|
|
|
272
|
-
|
|
190
|
+
**Scenario:** A list of permissions where multiple can be selected.
|
|
273
191
|
|
|
274
192
|
```typescript
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
element.classList.add('is-invalid');
|
|
282
|
-
return {
|
|
283
|
-
key: 'email-validator',
|
|
284
|
-
message: 'Email is not valid'
|
|
285
|
-
};
|
|
286
|
-
}
|
|
287
|
-
element.classList.remove('is-invalid');
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
const form = new FormGroup({
|
|
291
|
-
email: ['', [EmailValidator]],
|
|
292
|
-
password: '',
|
|
193
|
+
@Form({
|
|
194
|
+
name: 'permissions-form',
|
|
195
|
+
multi: true // Enable multi-value binding for same-name inputs
|
|
196
|
+
})
|
|
197
|
+
form = new FormGroup({
|
|
198
|
+
roles: [] // Will be an array of values
|
|
293
199
|
});
|
|
294
|
-
|
|
295
|
-
form
|
|
296
|
-
.setParentElement(document.body)
|
|
297
|
-
.setOptions({ name: 'my-form' })
|
|
298
|
-
.prepareValues()
|
|
299
|
-
.setFormElement(form.querySelectForm(document.body))
|
|
300
|
-
.setInputs(form.mapEventToInputs(form.querySelectorAllInputs()));
|
|
301
|
-
|
|
302
200
|
```
|
|
303
201
|
|
|
304
202
|
```html
|
|
305
|
-
<
|
|
306
|
-
<input
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
/>
|
|
313
|
-
|
|
314
|
-
name="password"
|
|
315
|
-
type="password"
|
|
316
|
-
required
|
|
317
|
-
/>
|
|
318
|
-
</form>
|
|
319
|
-
<script src="./main.ts"></script>
|
|
203
|
+
<label>
|
|
204
|
+
<input name="roles" type="checkbox" value="admin" /> Admin
|
|
205
|
+
</label>
|
|
206
|
+
<label>
|
|
207
|
+
<input name="roles" type="checkbox" value="editor" /> Editor
|
|
208
|
+
</label>
|
|
209
|
+
<label>
|
|
210
|
+
<input name="roles" type="checkbox" value="viewer" /> Viewer
|
|
211
|
+
</label>
|
|
320
212
|
```
|
|
321
213
|
|
|
322
|
-
|
|
214
|
+
If the user checks "Admin" and "Viewer", `form.value.roles` will be `['admin', 'viewer']`.
|
|
323
215
|
|
|
324
|
-
|
|
216
|
+
### 2. Single Selection Checkbox (Radio Behavior with Checkboxes)
|
|
325
217
|
|
|
326
|
-
|
|
327
|
-
import { FormArray, FormGroup } from '@rxdi/forms';
|
|
218
|
+
If you want multiple checkboxes to act like a radio button (only one valid at a time) but with uncheck capability:
|
|
328
219
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
220
|
+
```typescript
|
|
221
|
+
@Form({
|
|
222
|
+
name: 'settings-form',
|
|
223
|
+
multi: false // Default behavior
|
|
224
|
+
})
|
|
225
|
+
form = new FormGroup({
|
|
226
|
+
mode: ''
|
|
336
227
|
});
|
|
337
228
|
```
|
|
338
229
|
|
|
339
|
-
Template usage:
|
|
340
|
-
|
|
341
230
|
```html
|
|
342
|
-
|
|
343
|
-
<
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
<input
|
|
349
|
-
name="users[${index}].email"
|
|
350
|
-
.value=${group.value.email}
|
|
351
|
-
/>
|
|
352
|
-
<button @click=${() => this.removeUser(index)}>Remove</button>
|
|
353
|
-
</div>
|
|
354
|
-
`)}
|
|
355
|
-
<button @click=${() => this.addUser()}>Add User</button>
|
|
356
|
-
```
|
|
357
|
-
|
|
358
|
-
Adding items dynamically:
|
|
359
|
-
|
|
360
|
-
```typescript
|
|
361
|
-
addUser() {
|
|
362
|
-
(this.form.get('users') as FormArray).push(
|
|
363
|
-
new FormGroup({ name: '', email: '' })
|
|
364
|
-
);
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
removeUser(index: number) {
|
|
368
|
-
(this.form.get('users') as FormArray).removeAt(index);
|
|
369
|
-
}
|
|
231
|
+
<label>
|
|
232
|
+
<input name="mode" type="checkbox" value="dark" /> Dark
|
|
233
|
+
</label>
|
|
234
|
+
<label>
|
|
235
|
+
<input name="mode" type="checkbox" value="light" /> Light
|
|
236
|
+
</label>
|
|
370
237
|
```
|
|
238
|
+
Checking "Dark" unchecks "Light" automatically.
|
|
371
239
|
|
|
372
|
-
|
|
240
|
+
### 3. Framework-Agnostic Usage (Vanilla JS)
|
|
373
241
|
|
|
374
|
-
|
|
242
|
+
You can use this library without Decorators or LitHtml, with any UI library or vanilla HTML.
|
|
375
243
|
|
|
376
244
|
```typescript
|
|
377
|
-
|
|
378
|
-
@Form({ name: 'my-form' })
|
|
379
|
-
form: FormGroup; // Must be typed as FormGroup!
|
|
380
|
-
}
|
|
381
|
-
```
|
|
382
|
-
|
|
383
|
-
If you incorrectly type it (e.g., `form: string`), the library will throw a helpful error at runtime (requires `emitDecoratorMetadata: true` in `tsconfig`).
|
|
245
|
+
import { FormGroup } from '@rxdi/forms';
|
|
384
246
|
|
|
385
|
-
|
|
247
|
+
const form = new FormGroup({
|
|
248
|
+
email: '',
|
|
249
|
+
password: ''
|
|
250
|
+
});
|
|
386
251
|
|
|
387
|
-
|
|
388
|
-
|
|
252
|
+
// manually attach to DOM
|
|
253
|
+
const formElement = document.querySelector('form');
|
|
254
|
+
form
|
|
255
|
+
.setParentElement(document.body)
|
|
256
|
+
.setOptions({ name: 'my-form' })
|
|
257
|
+
.setFormElement(formElement)
|
|
258
|
+
.prepareValues()
|
|
259
|
+
.setInputs(form.mapEventToInputs(form.querySelectorAllInputs()));
|
|
389
260
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
};
|
|
261
|
+
// Listen to changes
|
|
262
|
+
form.valueChanges.subscribe(val => console.log(val));
|
|
393
263
|
```
|
|
394
264
|
|
|
395
|
-
|
|
265
|
+
### 4. Custom Error Handling Strategies
|
|
396
266
|
|
|
397
|
-
|
|
267
|
+
By default, verification happens on `change` or `blur`. You can control this via `strategy`.
|
|
398
268
|
|
|
399
269
|
```typescript
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
/**
|
|
404
|
-
* Helper to display errors safely.
|
|
405
|
-
* Checks 'touched' (user interacted) and 'validity.valid' (silent check).
|
|
406
|
-
*/
|
|
407
|
-
function ErrorTemplate(input: AbstractInput) {
|
|
408
|
-
if (input && input.touched && !input.validity.valid) {
|
|
409
|
-
return html`<div class="error">${input.validationMessage}</div>`;
|
|
410
|
-
}
|
|
411
|
-
return html``;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
@Component({
|
|
415
|
-
selector: 'user-profile-form',
|
|
416
|
-
template(this: UserProfileForm) {
|
|
417
|
-
return html`
|
|
418
|
-
<form
|
|
419
|
-
name="user-form"
|
|
420
|
-
@submit=${(e: Event) => {
|
|
421
|
-
e.preventDefault();
|
|
422
|
-
console.log('Form Value:', this.form.value);
|
|
423
|
-
}}
|
|
424
|
-
>
|
|
425
|
-
<!-- Email Field -->
|
|
426
|
-
<div>
|
|
427
|
-
<label>Email</label>
|
|
428
|
-
<input
|
|
429
|
-
type="email"
|
|
430
|
-
name="email"
|
|
431
|
-
required
|
|
432
|
-
.value=${this.form.value.email}
|
|
433
|
-
@blur=${() => this.requestUpdate()}
|
|
434
|
-
/>
|
|
435
|
-
<!--
|
|
436
|
-
@blur triggers a re-render so 'touched' state is reflected.
|
|
437
|
-
The library handles 'touched' internally on blur, but we need
|
|
438
|
-
to request an update to show the error message immediately.
|
|
439
|
-
-->
|
|
440
|
-
${ErrorTemplate(this.form.get('email'))}
|
|
441
|
-
</div>
|
|
442
|
-
|
|
443
|
-
<!-- Password Field -->
|
|
444
|
-
<div>
|
|
445
|
-
<label>Password</label>
|
|
446
|
-
<input
|
|
447
|
-
type="password"
|
|
448
|
-
name="password"
|
|
449
|
-
required
|
|
450
|
-
minlength="6"
|
|
451
|
-
.value=${this.form.value.password}
|
|
452
|
-
@blur=${() => this.requestUpdate()}
|
|
453
|
-
/>
|
|
454
|
-
${ErrorTemplate(this.form.get('password'))}
|
|
455
|
-
</div>
|
|
456
|
-
|
|
457
|
-
<button type="submit" ?disabled=${this.form.invalid}>
|
|
458
|
-
Save Profile
|
|
459
|
-
</button>
|
|
460
|
-
</form>
|
|
461
|
-
`;
|
|
462
|
-
},
|
|
270
|
+
@Form({
|
|
271
|
+
name: 'login',
|
|
272
|
+
strategy: 'input' // Validate on every keystroke
|
|
463
273
|
})
|
|
464
|
-
export class UserProfileForm extends LitElement {
|
|
465
|
-
@Form({
|
|
466
|
-
name: 'user-form', // Must match <form name="...">
|
|
467
|
-
strategy: 'change', // Validate on change/blur
|
|
468
|
-
})
|
|
469
|
-
form = new FormGroup({
|
|
470
|
-
email: '',
|
|
471
|
-
password: '',
|
|
472
|
-
});
|
|
473
|
-
}
|
|
474
274
|
```
|
|
475
275
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
1. **Name Matching**: The `<form name="X">` attribute must match the `@Form({ name: 'X' })` name.
|
|
479
|
-
2. **Input Binding**: Use `.value=${this.form.value.fieldName}` to bind data. The library updates the model on user input automatically.
|
|
480
|
-
3. **Error Display**: Use a helper like `ErrorTemplate` that checks `.touched` and `.validity.valid`.
|
|
481
|
-
* **Wait until touched**: `if (input.touched)` prevents errors from showing on load.
|
|
482
|
-
* **Silent Check**: `!input.validity.valid` prevents side effects (looping).
|
|
483
|
-
4. **Re-rendering**: Since validation often updates on `blur`, ensure your component re-renders to show the error state (e.g., via `@blur=${() => this.requestUpdate()}` or generic event handlers).
|
|
484
|
-
|
|
485
|
-
#### Complete Nested Form Example (FormArray)
|
|
486
|
-
|
|
487
|
-
This example demonstrates managing a dynamic list of items (e.g., Team Members) using `FormArray`.
|
|
276
|
+
You can also manually check error states (e.g. for async validation):
|
|
488
277
|
|
|
489
278
|
```typescript
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
if (input && input.touched && !input.validity.valid) {
|
|
495
|
-
return html`<div class="error">${input.validationMessage}</div>`;
|
|
279
|
+
async validateEmail(element: HTMLInputElement) {
|
|
280
|
+
const exists = await checkServer(element.value);
|
|
281
|
+
if (exists) {
|
|
282
|
+
return { key: 'emailExists', message: 'Email already taken' };
|
|
496
283
|
}
|
|
497
|
-
return html``;
|
|
498
284
|
}
|
|
499
285
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
<form @submit=${(e) => e.preventDefault()}>
|
|
505
|
-
|
|
506
|
-
<!-- Main Form Field -->
|
|
507
|
-
<div>
|
|
508
|
-
<label>Team Name</label>
|
|
509
|
-
<input
|
|
510
|
-
name="teamName"
|
|
511
|
-
required
|
|
512
|
-
.value=${this.form.value.teamName}
|
|
513
|
-
@blur=${() => this.requestUpdate()}
|
|
514
|
-
/>
|
|
515
|
-
${ErrorTemplate(this.form.get('teamName'))}
|
|
516
|
-
</div>
|
|
517
|
-
|
|
518
|
-
<h3>Members</h3>
|
|
519
|
-
|
|
520
|
-
<!-- FormArray Iteration -->
|
|
521
|
-
${this.members.controls.map((control, index) => html`
|
|
522
|
-
<div class="member-row">
|
|
523
|
-
|
|
524
|
-
<!-- Nested Field: Name -->
|
|
525
|
-
<!-- Notice the name syntax: arrayName[index].fieldName -->
|
|
526
|
-
<div class="field">
|
|
527
|
-
<input
|
|
528
|
-
placeholder="Member Name"
|
|
529
|
-
name="members[${index}].name"
|
|
530
|
-
required
|
|
531
|
-
.value=${control.value.name}
|
|
532
|
-
@blur=${() => this.requestUpdate()}
|
|
533
|
-
/>
|
|
534
|
-
<!-- Access nested control using .get() on the group -->
|
|
535
|
-
${ErrorTemplate(control.get('name'))}
|
|
536
|
-
</div>
|
|
537
|
-
|
|
538
|
-
<!-- Nested Field: Role -->
|
|
539
|
-
<div class="field">
|
|
540
|
-
<input
|
|
541
|
-
placeholder="Role"
|
|
542
|
-
name="members[${index}].role"
|
|
543
|
-
required
|
|
544
|
-
.value=${control.value.role}
|
|
545
|
-
@blur=${() => this.requestUpdate()}
|
|
546
|
-
/>
|
|
547
|
-
${ErrorTemplate(control.get('role'))}
|
|
548
|
-
</div>
|
|
549
|
-
|
|
550
|
-
<button type="button" @click=${() => this.removeMember(index)}>
|
|
551
|
-
Remove
|
|
552
|
-
</button>
|
|
553
|
-
</div>
|
|
554
|
-
`)}
|
|
555
|
-
|
|
556
|
-
<button type="button" @click=${() => this.addMember()}>
|
|
557
|
-
Add Member
|
|
558
|
-
</button>
|
|
559
|
-
|
|
560
|
-
<button type="submit" ?disabled=${this.form.invalid}>
|
|
561
|
-
Submit Team
|
|
562
|
-
</button>
|
|
563
|
-
</form>
|
|
564
|
-
`;
|
|
565
|
-
},
|
|
566
|
-
})
|
|
567
|
-
export class TeamForm extends LitElement {
|
|
568
|
-
@Form({
|
|
569
|
-
name: 'team-form',
|
|
570
|
-
strategy: 'change',
|
|
571
|
-
})
|
|
572
|
-
form = new FormGroup({
|
|
573
|
-
teamName: '',
|
|
574
|
-
members: new FormArray<{ name: string; role: string }>([]),
|
|
575
|
-
});
|
|
576
|
-
|
|
577
|
-
// Helper to cast the control to FormArray for better typing
|
|
578
|
-
get members() {
|
|
579
|
-
return this.form.get('members') as FormArray;
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
addMember() {
|
|
583
|
-
this.members.push(
|
|
584
|
-
new FormGroup({
|
|
585
|
-
name: '',
|
|
586
|
-
role: '',
|
|
587
|
-
})
|
|
588
|
-
);
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
removeMember(index: number) {
|
|
592
|
-
this.members.removeAt(index);
|
|
593
|
-
}
|
|
594
|
-
}
|
|
286
|
+
// In Template
|
|
287
|
+
${this.form.hasError('email', 'emailExists')
|
|
288
|
+
? html`<div class="error">Email taken!</div>`
|
|
289
|
+
: ''}
|
|
595
290
|
```
|
package/dist/form.array.js
CHANGED
package/dist/form.decorator.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.Form = Form;
|
|
4
|
-
|
|
4
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
5
5
|
const rxjs_1 = require("rxjs");
|
|
6
|
+
const form_group_1 = require("./form.group");
|
|
6
7
|
function Form(options = {
|
|
7
8
|
strategy: 'none',
|
|
8
9
|
}) {
|
|
@@ -18,6 +19,9 @@ function Form(options = {
|
|
|
18
19
|
throw new Error('Value provided is not an instance of FormGroup!');
|
|
19
20
|
}
|
|
20
21
|
this[name].setParentElement(this).setOptions(options).prepareValues();
|
|
22
|
+
if (options.model && this[options.model]) {
|
|
23
|
+
this[name].patchValue(this[options.model]);
|
|
24
|
+
}
|
|
21
25
|
return Connect.call(this);
|
|
22
26
|
};
|
|
23
27
|
clazz.constructor.prototype.firstUpdated = function () {
|
package/dist/form.group.d.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { LitElement } from '@rxdi/lit-html';
|
|
2
|
-
import { AbstractControl, AbstractInput, ErrorObject, FormInputOptions, FormOptions, ValidatorFn } from './form.tokens';
|
|
2
|
+
import { AbstractControl, AbstractInput, ErrorObject, FormInputOptions, FormOptions, NestedKeyOf, UnwrapValue, ValidatorFn } from './form.tokens';
|
|
3
3
|
export declare class FormGroup<T = FormInputOptions, E = {
|
|
4
4
|
[key: string]: never;
|
|
5
|
-
}> implements AbstractControl<T
|
|
5
|
+
}> implements AbstractControl<UnwrapValue<T>> {
|
|
6
6
|
validators: Map<string, ValidatorFn[]>;
|
|
7
7
|
valid: boolean;
|
|
8
8
|
invalid: boolean;
|
|
9
|
-
errors: T
|
|
9
|
+
errors: UnwrapValue<T>;
|
|
10
10
|
private controls;
|
|
11
11
|
private readonly _valueChanges;
|
|
12
12
|
private form;
|
|
@@ -22,7 +22,7 @@ export declare class FormGroup<T = FormInputOptions, E = {
|
|
|
22
22
|
getParentElement(): LitElement;
|
|
23
23
|
setOptions(options: FormOptions): this;
|
|
24
24
|
getOptions(): FormOptions;
|
|
25
|
-
get valueChanges(): import("rxjs").Observable<T
|
|
25
|
+
get valueChanges(): import("rxjs").Observable<UnwrapValue<T>>;
|
|
26
26
|
updateValueAndValidity(): Promise<(ErrorObject | {
|
|
27
27
|
message: string;
|
|
28
28
|
})[]>;
|
|
@@ -38,18 +38,19 @@ export declare class FormGroup<T = FormInputOptions, E = {
|
|
|
38
38
|
private getModelKeyName;
|
|
39
39
|
validate(element: AbstractInput): Promise<ErrorObject>;
|
|
40
40
|
private mapInputErrors;
|
|
41
|
-
get(name:
|
|
41
|
+
get<K extends NestedKeyOf<T>>(name: K): AbstractControl | AbstractInput;
|
|
42
42
|
getError(inputName: keyof T, errorKey: string): never;
|
|
43
43
|
hasError(inputName: keyof T, errorKey: string): boolean;
|
|
44
44
|
reset(): void;
|
|
45
45
|
setFormValidity(validity?: boolean): void;
|
|
46
46
|
resetErrors(): void;
|
|
47
|
-
get value(): T
|
|
48
|
-
set value(value: T);
|
|
47
|
+
get value(): UnwrapValue<T>;
|
|
48
|
+
set value(value: UnwrapValue<T>);
|
|
49
49
|
unsubscribe(): void;
|
|
50
50
|
getValue(name: keyof T): T[keyof T];
|
|
51
|
+
patchValue(value: Partial<UnwrapValue<T>>): void;
|
|
51
52
|
setValue(name: keyof T, value: unknown): void;
|
|
52
|
-
setFormValue(value: T): void;
|
|
53
|
+
setFormValue(value: UnwrapValue<T>): void;
|
|
53
54
|
setFormElement(form: HTMLFormElement): this;
|
|
54
55
|
setInputs(inputs: AbstractInput[]): void;
|
|
55
56
|
getFormElement(): HTMLFormElement;
|
package/dist/form.group.js
CHANGED
|
@@ -26,7 +26,10 @@ class FormGroup {
|
|
|
26
26
|
this._valueChanges = new rxjs_1.BehaviorSubject(value);
|
|
27
27
|
if (value) {
|
|
28
28
|
Object.keys(value).forEach((key) => {
|
|
29
|
-
if (typeof value[key] === 'object' &&
|
|
29
|
+
if (typeof value[key] === 'object' &&
|
|
30
|
+
value[key] !== null &&
|
|
31
|
+
(value[key]['controls'] || value[key]['push']) &&
|
|
32
|
+
value[key]['valueChanges']) {
|
|
30
33
|
// It's likely a FormGroup or FormArray
|
|
31
34
|
const control = value[key];
|
|
32
35
|
if (control.name === '' || control.name === undefined) {
|
|
@@ -63,7 +66,12 @@ class FormGroup {
|
|
|
63
66
|
this.validators.set(v, [...oldValidators, val]);
|
|
64
67
|
});
|
|
65
68
|
}
|
|
66
|
-
if (value[0]
|
|
69
|
+
if (value[0] === undefined || value[0] === null) {
|
|
70
|
+
this.value[v] = '';
|
|
71
|
+
}
|
|
72
|
+
else if (value[0].constructor === String ||
|
|
73
|
+
value[0].constructor === Number ||
|
|
74
|
+
value[0].constructor === Boolean) {
|
|
67
75
|
this.value[v] = value[0];
|
|
68
76
|
}
|
|
69
77
|
else {
|
|
@@ -88,8 +96,9 @@ class FormGroup {
|
|
|
88
96
|
setOptions(options) {
|
|
89
97
|
this.options = options;
|
|
90
98
|
this.controls.forEach((c) => {
|
|
91
|
-
if (c.setOptions)
|
|
92
|
-
c.setOptions(options);
|
|
99
|
+
if (c.setOptions) {
|
|
100
|
+
c.setOptions(Object.assign(Object.assign({}, options), { namespace: this.options.namespace ? `${this.options.namespace}.${c.name}` : c.name }));
|
|
101
|
+
}
|
|
93
102
|
});
|
|
94
103
|
return this;
|
|
95
104
|
}
|
|
@@ -322,6 +331,14 @@ class FormGroup {
|
|
|
322
331
|
if (this.controls.has(name)) {
|
|
323
332
|
return this.controls.get(name);
|
|
324
333
|
}
|
|
334
|
+
if (String(name).includes('.')) {
|
|
335
|
+
const names = String(name).split('.');
|
|
336
|
+
const key = names.shift();
|
|
337
|
+
const control = this.controls.get(key);
|
|
338
|
+
if (control && control.get) {
|
|
339
|
+
return control.get(names.join('.'));
|
|
340
|
+
}
|
|
341
|
+
}
|
|
325
342
|
return this.inputs.get(name);
|
|
326
343
|
}
|
|
327
344
|
getError(inputName, errorKey) {
|
|
@@ -368,6 +385,19 @@ class FormGroup {
|
|
|
368
385
|
getValue(name) {
|
|
369
386
|
return this.value[name];
|
|
370
387
|
}
|
|
388
|
+
patchValue(value) {
|
|
389
|
+
if (!value) {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
Object.keys(value).forEach((key) => {
|
|
393
|
+
if (this.controls.has(key) && this.controls.get(key)['patchValue']) {
|
|
394
|
+
this.controls.get(key)['patchValue'](value[key]);
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
this.setValue(key, value[key]);
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
}
|
|
371
401
|
setValue(name, value) {
|
|
372
402
|
const input = this.get(name);
|
|
373
403
|
if (!input) {
|
package/dist/form.tokens.d.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { LitElement } from '@rxdi/lit-html';
|
|
2
2
|
import { Observable } from 'rxjs';
|
|
3
|
+
export type UnwrapValue<T> = T extends AbstractControl<infer U> ? U : T extends {
|
|
4
|
+
[key: string]: any;
|
|
5
|
+
} ? {
|
|
6
|
+
[K in keyof T]: UnwrapValue<T[K]>;
|
|
7
|
+
} : T;
|
|
8
|
+
type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...0[]];
|
|
9
|
+
export type NestedKeyOf<T, D extends number = 3> = [D] extends [0] ? never : T extends object ? {
|
|
10
|
+
[K in keyof T & (string | number)]: T[K] extends AbstractControl<infer U> ? U extends object ? `${K}` | `${K}.${NestedKeyOf<U, Prev[D]>}` : `${K}` : T[K] extends object ? `${K}` | `${K}.${NestedKeyOf<T[K], Prev[D]>}` : `${K}`;
|
|
11
|
+
}[keyof T & (string | number)] : never;
|
|
3
12
|
export type FormStrategies = keyof WindowEventMap;
|
|
4
13
|
export interface FormOptions {
|
|
5
14
|
/** Name of the form element */
|
|
@@ -20,6 +29,10 @@ export interface FormOptions {
|
|
|
20
29
|
* Internal property for handling nested forms.
|
|
21
30
|
*/
|
|
22
31
|
namespace?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Property name of the model to bind to the form
|
|
34
|
+
*/
|
|
35
|
+
model?: string;
|
|
23
36
|
}
|
|
24
37
|
export interface AbstractControl<T = any> {
|
|
25
38
|
setOptions(options: FormOptions): this | void;
|
|
@@ -69,3 +82,4 @@ export declare const InputValidityState: {
|
|
|
69
82
|
valueMissing: "valueMissing";
|
|
70
83
|
};
|
|
71
84
|
export type InputValidityState = keyof typeof InputValidityState;
|
|
85
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,27 +1,27 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
2
|
+
"name": "@rxdi/forms",
|
|
3
|
+
"version": "0.7.214",
|
|
4
|
+
"main": "./dist/index.js",
|
|
5
|
+
"author": "Kristiyan Tachev",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/rxdi/forms"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@rxdi/lit-html": "^0.7.213",
|
|
16
|
+
"@types/node": "^25.0.3",
|
|
17
|
+
"rxjs": "^7.8.2",
|
|
18
|
+
"typescript": "^5.9.3"
|
|
19
|
+
},
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"rxjs": "^7.8.2",
|
|
22
|
+
"@rxdi/lit-html": "*"
|
|
23
|
+
},
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"module": "./dist/index.js",
|
|
26
|
+
"typings": "./dist/index.d.ts"
|
|
27
27
|
}
|