A lightweight, strongly-typed, reactive forms library for LitHtml applications.
npm install @rxdi/formsA lightweight, strongly-typed, reactive forms library for LitHtml applications.
- Strict Typing: Full TypeScript support with UnwrapValue and NestedKeyOf for deep property inference.
- Nested Forms: Support for deep FormGroup nesting and FormArray.
- Automatic Binding: Bind component models directly to forms with @Form({ model: 'myModel' }).
- Reactive: based on rxjs BehaviorSubject for value streams.
- Recursive Updates: patchValue updates deep structures recursively.
``bash`
npm i @rxdi/forms
`typescript
import { html, Component, LitElement } from '@rxdi/lit-html';
import { Form, FormGroup } from '@rxdi/forms';
interface UserParams {
firstName: string;
address: {
city: string;
street: string;
};
}
@Component({
selector: 'user-profile',
template(this: UserProfile) {
return html
;
},
})
export class UserProfile extends LitElement {
// Model to bind
@property({ type: Object })
user: UserParams = {
firstName: 'John',
address: { city: 'New York', street: '5th Ave' },
}; @Form({
name: 'user-form',
strategy: 'change',
model: 'user', // Automatic Model Binding!
})
form = new FormGroup({
firstName: '',
address: new FormGroup({
city: '',
street: '',
}),
});
onSubmit(e: Event) {
e.preventDefault();
console.log(this.form.value);
// Output: { firstName: 'John', address: { city: 'New York', street: '5th Ave' } }
}
}
`New Features
$3
Use the
model property in the @Form decorator to automatically populate the form from a component property.`typescript
@Form({
name: 'my-form',
model: 'myData' // matches this.myData
})
form = new FormGroup({ ... });
`The library reads
this.myData during initialization and calls form.patchValue(this.myData).$3
You can nest
FormGroups arbitrarily deep.`typescript
form = new FormGroup({
meta: new FormGroup({
id: 1,
flags: new FormGroup({
isActive: true,
isAdmin: false,
}),
}),
tags: new FormArray([new FormGroup({ label: 'red' })]),
});
`Template Binding:
Use dot notation for nested controls:
`html
`$3
The library now extensively uses advanced TypeScript features:
-
form.value: Returns the unwrapped pure object type (e.g., { meta: { flags: { isActive: boolean } } }).
- form.get('path.to.prop'): Provides autocomplete for deep paths and infers return types!
- form.get('key') returns exact control type (e.g. FormArray) without casting.`typescript
// TypeScript knows this is valid:
this.form.get('meta.flags.isActive');// And this is invalid:
this.form.get('meta.flags.wrongProp'); // Error!
`$3
Update multiple fields deeply at once:
``typescript
this.form.patchValue({
meta: {
flags: {
isActive: false
}
}
});
// Only updates 'isActive', leaves other fields untouched.
``$3
For lists of primitive values, use
FormArray with an itemFactory and automatic model binding. This removes the need for manual population.#### Full Working Example
`typescript
import { Component, html, LitElement, property } from '@rxdi/lit-html';
import { Form, FormGroup, FormArray } from '@rxdi/forms';
@Component({
selector: 'tags-component',
template(this: TagsComponent) {
return html
;
},
})
export class TagsComponent extends LitElement {
// Model automatically binds to 'tags' in form
@property({ type: Array })
tags = ['news', 'tech']; @Form({
name: 'tags-form',
model: 'tags', // Triggers form.patchValue(this.tags) on INIT
})
form = new FormGroup({
tags: new FormArray<{ value: string }>(
[],
(value) => new FormGroup({ value: value.value || value }) // Factory handles population automatically
),
});
addTag() {
this.form.get('tags').push(new FormGroup({ value: '' }));
}
removeTag(index: number) {
this.form.get('tags').removeAt(index);
}
onSubmit() {
const dirtyTags = this.form.value.tags;
console.log(dirtyTags.map((t) => t.value));
}
}
``$3
You can subscribe to
valueChanges on individual inputs, even if they aren't in the DOM yet!`typescript
// Works even if 'email' input is inside an *ngIf or not yet rendered
this.form.get('email').valueChanges.subscribe(value => {
console.log('Email changed:', value); // 'value' is strongly typed!
});
`This is powered by "Virtual Inputs" which mock the input interface if the model key exists but the DOM element is missing.
$3
The
valueChanges observable is powerful for creating interactive forms.#### 1. Debounced Search
`typescript
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';this.form.get('search').valueChanges.pipe(
debounceTime(300),
distinctUntilChanged()
).subscribe(term => {
this.searchService.search(term);
});
`#### 2. Dependant Fields (Cascading Dropdowns)
Reset or modify dependent fields when a parent field changes.
`typescript
this.form.get('country').valueChanges.subscribe(country => {
// Reset state when country changes
this.form.get('state').value = '';
// Update state options dynamically based on country
this.loadStatesFor(country);
});
`#### 3. Dynamic Disabling
Disable controls based on the value of others.
`typescript
this.form.get('subscribeNewsletter').valueChanges.subscribe(shouldSubscribe => {
const emailControl = this.form.get('newsletterEmail');
if (shouldSubscribe) {
emailControl.disabled = false;
} else {
emailControl.disabled = true;
emailControl.value = ''; // Optional: clear value
}
});
`$3
AbstractInput is now generic, propagating types through the form.`typescript
const emailInput: AbstractInput = this.form.get('email');
// emailInput.value is string
`API Reference
$3
Validators are async functions returning
InputErrorMessage or void.`typescript
export function CustomValidator(element: AbstractInput) {
if (element.value === 'invalid') {
return { key: 'customError', message: 'Value is invalid' };
}
}// Usage
new FormGroup({
field: ['', [CustomValidator]],
});
`$3
Use the
touched and validity.valid properties for clean UI.`typescript
function ErrorTemplate(input: AbstractInput) {
if (input?.touched && !input.validity.valid) {
return html;
}
return html;
}
`Advanced Usage
$3
By default (
multi: false), inputs with the same name attribute behave like radio buttons (single selection). To allow multiple selections (array of values), set multi: true in the form options.Scenario: A list of permissions where multiple can be selected.
`typescript
@Form({
name: 'permissions-form',
multi: true // Enable multi-value binding for SAME-NAME inputs
})
form = new FormGroup({
roles: [] // Will be an array of values ['admin', 'viewer']
});
``html
`$3
If you want multiple checkboxes to act like a radio button (only one valid at a time) but with uncheck capability, simply use the default
multi: false.`typescript
@Form({
name: 'settings-form',
// multi: false // Default behavior
})
form = new FormGroup({
mode: '' // Will be a single string 'dark' or 'light'
});
``html
`$3
You can mix single-select and multi-select groups in the same form by keeping the global
multi: false (default) and adding the multiple attribute to specific inputs.`typescript
@Form({
name: 'mixed-form',
multi: false // Default (Single Select)
})
form = new FormGroup({
mode: '', // Single value
tags: [] // Array of values
});
``html
`$3
You can use this library without Decorators or LitHtml, with any UI library or vanilla HTML.
`typescript
import { FormGroup } from '@rxdi/forms';const form = new FormGroup({
email: '',
password: '',
});
// manually attach to DOM
const formElement = document.querySelector('form');
form
.setParentElement(document.body)
.setOptions({ name: 'my-form' })
.setFormElement(formElement)
.prepareValues()
.setInputs(form.mapEventToInputs(form.querySelectorAllInputs()));
// Listen to changes
form.valueChanges.subscribe((val) => console.log(val));
`$3
By default, verification happens on
change or blur. You can control this via strategy.`typescript
@Form({
name: 'login',
strategy: 'input' // Validate on every keystroke
})
`You can also manually check error states (e.g. for async validation):
`typescript
async validateEmail(element: HTMLInputElement) {
const exists = await checkServer(element.value);
if (exists) {
return { key: 'emailExists', message: 'Email already taken' };
}
}// In Template
${this.form.hasError('email', 'emailExists')
? html
: ''}
``