@rxdi/forms
v0.7.234
Published
A lightweight, strongly-typed, reactive forms library for LitHtml applications.
Readme
Reactive Forms for LitHtml (Enhanced)
A lightweight, strongly-typed, reactive forms library for LitHtml applications.
Features
- Strict Typing: Full TypeScript support with
UnwrapValueandNestedKeyOffor deep property inference. - Nested Forms: Support for deep
FormGroupnesting andFormArray. - Automatic Binding: Bind component models directly to forms with
@Form({ model: 'myModel' }). - Reactive: based on
rxjsBehaviorSubjectfor value streams. - Recursive Updates:
patchValueupdates deep structures recursively.
Installation
npm i @rxdi/formsBasic Usage
1. Define Model & Component
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`
<form name="user-form" @submit=${this.onSubmit}>
<!-- Deep Binding with Dot Notation -->
<input
name="firstName"
.value=${this.form.value.firstName}
@blur=${() => this.requestUpdate()}
/>
<!-- Nested Group Binding -->
<input
name="address.city"
.value=${this.form.value.address.city}
@blur=${() => this.requestUpdate()}
/>
<button type="submit">Save</button>
</form>
`;
},
})
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
Automatic Model Binding
Use the model property in the @Form decorator to automatically populate the form from a component property.
@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).
Nested FormGroups & FormArray
You can nest FormGroups arbitrarily deep.
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:
<input name="meta.flags.isActive" type="checkbox" />Type Safety & Autosuggestion
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 knows this is valid:
this.form.get('meta.flags.isActive');
// And this is invalid:
this.form.get('meta.flags.wrongProp'); // Error!Recursive PatchValue
Update multiple fields deeply at once:
this.form.patchValue({
meta: {
flags: {
isActive: false
}
}
});
// Only updates 'isActive', leaves other fields untouched.Dynamic Array Inputs (FormArray)
For lists of primitive values, use FormArray with an itemFactory and automatic model binding. This removes the need for manual population.
Full Working Example
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`
<form @submit=${(e) => e.preventDefault()}>
<h3>Tags</h3>
<!-- List Tags -->
${this.form.get('tags').controls.map(
(control, index) => html`
<div class="tag-row">
<input name="tags[${index}].value" .value=${control.value.value} @blur=${() => this.requestUpdate()} />
<button type="button" @click=${() => this.removeTag(index)}>Remove</button>
</div>
`
)}
<button type="button" @click=${() => this.addTag()}>Add Tag</button>
<button type="button" @click=${() => this.onSubmit()}>Submit</button>
</form>
`;
},
})
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));
}
}
Typed Subscriptions & Virtual Inputs
You can subscribe to valueChanges on individual inputs, even if they aren't in the DOM yet!
// 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.
Working with Value Changes
The valueChanges observable is powerful for creating interactive forms.
1. Debounced Search
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.
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.
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
}
});Generic Typed AbstractInput
AbstractInput is now generic, propagating types through the form.
const emailInput: AbstractInput<string> = this.form.get('email');
// emailInput.value is stringAPI Reference
Validators
Validators are async functions returning InputErrorMessage or void.
export function CustomValidator(element: AbstractInput) {
if (element.value === 'invalid') {
return { key: 'customError', message: 'Value is invalid' };
}
}
// Usage
new FormGroup({
field: ['', [CustomValidator]],
});Error Display Information
Use the touched and validity.valid properties for clean UI.
function ErrorTemplate(input: AbstractInput) {
if (input?.touched && !input.validity.valid) {
return html`<div class="error">${input.validationMessage}</div>`;
}
return html``;
}Advanced Usage
1. Grouping Multiple Inputs (Checkbox Groups)
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.
@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']
});<label> <input name="roles" type="checkbox" value="admin" /> Admin </label>
<label> <input name="roles" type="checkbox" value="editor" /> Editor </label>
<label> <input name="roles" type="checkbox" value="viewer" /> Viewer </label>2. Single Selection Checkbox (Default Behavior)
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.
@Form({
name: 'settings-form',
// multi: false // Default behavior
})
form = new FormGroup({
mode: '' // Will be a single string 'dark' or 'light'
});<label> <input name="mode" type="checkbox" value="dark" /> Dark </label>
<label> <input name="mode" type="checkbox" value="light" /> Light </label>3. Per-Field Multi-Select Override
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.
@Form({
name: 'mixed-form',
multi: false // Default (Single Select)
})
form = new FormGroup({
mode: '', // Single value
tags: [] // Array of values
});<!-- Single Select (Radio behavior) -->
<input name="mode" type="checkbox" value="A" />
<input name="mode" type="checkbox" value="B" />
<!-- Multi Select (Array behavior) via 'multiple' attribute -->
<input name="tags" type="checkbox" value="news" multiple />
<input name="tags" type="checkbox" value="tech" multiple />4. Framework-Agnostic Usage (Vanilla JS)
You can use this library without Decorators or LitHtml, with any UI library or vanilla HTML.
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));4. Custom Error Handling Strategies
By default, verification happens on change or blur. You can control this via strategy.
@Form({
name: 'login',
strategy: 'input' // Validate on every keystroke
})You can also manually check error states (e.g. for async validation):
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`<div class="error">Email taken!</div>`
: ''}