@zellvora/ng-input
v0.1.4
Published
Enterprise Angular 21 form controls supporting Template-Driven, Reactive, and Signal Forms.
Readme
@zellvora/ng-input - Complete Guide
Signal-first form controls for Angular 22. This guide covers everything you need to know about using @zellvora/ng-input, including the Signal Form Engine, 15 components, and three different binding approaches.
Table of Contents
- Installation & Setup
- Core Concepts
- Three Binding Approaches
- 15 Form Controls
- Validation System
- Advanced Topics
Installation & Setup
Step 1: Install Package
npm install @zellvora/ng-inputStep 2: Add Provider
In your app.config.ts (standalone) or app.module.ts:
import { ApplicationConfig } from '@angular/core';
import { provideZvInput } from '@zellvora/ng-input';
export const appConfig: ApplicationConfig = {
providers: [
provideZvInput(), // Add this
],
};This provider sets up default theming, error messages, and configuration.
Step 3: Import Components
Each control can be imported independently:
import {
ZvInput,
ZvEmail,
ZvPassword,
ZvSelect
} from '@zellvora/ng-input';
@Component({
standalone: true,
imports: [ZvInput, ZvEmail, ZvPassword, ZvSelect],
// ...
})
export class MyForm {}Core Concepts
What is Signal-First?
Traditional Angular forms use classes and decorators:
FormBuilderto create forms@Input/@Outputdecorators for data flow- Subscriptions to track changes
- Manual change detection
Zellvora's Signal-First approach:
createForm()function to define formsinput(),model(),output()Signal functions- Automatic reactivity (no subscriptions needed)
- Computed values that update automatically
The Signal Form Engine
The Signal Form Engine is the single source of truth. It's a custom engine (not Angular's @angular/forms/signals).
User Input → Control Component → Signal Form Engine → Model Signal
↓
(updateValue)
↓
Model Updated Reactively
↓
All computed signals update automatically
(errors, valid, touched, dirty, etc.)Key insight: The model is a WritableSignal<T>. Every control field is a computed view of the model. Validation happens automatically when the model changes.
Three Binding Approaches
Overview
| Approach | Use Case | Learning Curve | Type Safety | Reactivity | |----------|----------|-----------------|-------------|------------| | Template Forms | Quick prototypes, simple forms | Easy | Loose | Good | | Reactive Forms | Large apps, complex forms | Medium | Good | Good | | Signal Forms | Enterprise apps, modern Angular | Medium | Excellent | Excellent |
Template Forms
Template Forms use [(ngModel)] two-way binding. This is the easiest way to get started with Zellvora controls.
When to Use Template Forms
- Quick prototypes and proof-of-concepts
- Simple forms with few validations
- Learning Zellvora for the first time
- Small internal tools
Basic Template Form Example
import { Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ZvInput, ZvEmail, ZvPassword } from '@zellvora/ng-input';
@Component({
selector: 'app-login',
standalone: true,
imports: [FormsModule, ZvInput, ZvEmail, ZvPassword],
template: `
<div class="login-form">
<h2>Login</h2>
<!-- Email input with two-way binding -->
<zv-email
[(ngModel)]="email"
name="email"
label="Email Address"
placeholder="[email protected]"
required
></zv-email>
<!-- Password input with two-way binding -->
<zv-password
[(ngModel)]="password"
name="password"
label="Password"
placeholder="Enter your password"
required
></zv-password>
<!-- Display current values -->
<div class="debug">
<p>Email: {{ email }}</p>
<p>Password: {{ password }}</p>
</div>
<!-- Submit button -->
<button (click)="login()" [disabled]="!email || !password">
Login
</button>
</div>
`,
styles: [`
.login-form {
max-width: 400px;
margin: 2rem auto;
padding: 2rem;
}
.debug {
background: #f3f4f6;
padding: 1rem;
border-radius: 4px;
margin: 1rem 0;
font-size: 0.875rem;
}
button {
width: 100%;
padding: 0.75rem;
margin-top: 1rem;
}
`],
})
export class LoginComponent {
email = signal('');
password = signal('');
login() {
console.log('Logging in with:', {
email: this.email(),
password: this.password(),
});
// Call your API here
}
}How it works:
emailandpasswordare signals (or plain values)[(ngModel)]="email"creates two-way binding- When user types → signal updates
- When signal updates → input value updates
name="email"is required by FormsModule
Multiple Fields in Template Form
import { Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
ZvInput,
ZvEmail,
ZvPassword,
ZvNumber,
ZvSelect,
ZvCheckbox,
} from '@zellvora/ng-input';
@Component({
selector: 'app-registration',
standalone: true,
imports: [
FormsModule,
ZvInput,
ZvEmail,
ZvPassword,
ZvNumber,
ZvSelect,
ZvCheckbox,
],
template: `
<form (ngSubmit)="register()">
<!-- Text input -->
<zv-input
[(ngModel)]="form.firstName"
name="firstName"
label="First Name"
placeholder="John"
required
></zv-input>
<!-- Email input -->
<zv-email
[(ngModel)]="form.email"
name="email"
label="Email"
required
></zv-email>
<!-- Password input -->
<zv-password
[(ngModel)]="form.password"
name="password"
label="Password"
required
></zv-password>
<!-- Number input -->
<zv-number
[(ngModel)]="form.age"
name="age"
label="Age"
[min]="18"
[max]="100"
></zv-number>
<!-- Select dropdown -->
<zv-select
[(ngModel)]="form.country"
name="country"
label="Country"
[items]="countries"
required
></zv-select>
<!-- Checkbox -->
<zv-checkbox
[(ngModel)]="form.agreeTerms"
name="agreeTerms"
label="I agree to terms"
required
></zv-checkbox>
<!-- Display form data -->
<div class="form-data">
<h3>Form Data:</h3>
<pre>{{ form | json }}</pre>
</div>
<!-- Submit -->
<button type="submit">Register</button>
</form>
`,
styles: [`
form {
max-width: 500px;
margin: 2rem auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-data {
background: #f3f4f6;
padding: 1rem;
border-radius: 4px;
margin: 1rem 0;
}
button {
padding: 0.75rem;
background: #2563eb;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
`],
})
export class RegistrationComponent {
countries = [
{ label: 'India', value: 'IN' },
{ label: 'USA', value: 'US' },
{ label: 'Canada', value: 'CA' },
];
form = signal({
firstName: '',
email: '',
password: '',
age: null as number | null,
country: '',
agreeTerms: false,
});
register() {
console.log('Registering with:', this.form());
}
}Validation in Template Forms
Template Forms with Zellvora don't have built-in validation at the form level. Validation happens in each control independently.
import { Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ZvEmail, ZvInput } from '@zellvora/ng-input';
@Component({
selector: 'app-email-form',
standalone: true,
imports: [FormsModule, ZvEmail, ZvInput],
template: `
<form (ngSubmit)="submit()">
<!-- Email with minlength and maxlength -->
<zv-email
[(ngModel)]="email"
name="email"
label="Email"
minlength="5"
maxlength="100"
required
></zv-email>
<!-- Name with min/max length validation attributes -->
<zv-input
[(ngModel)]="name"
name="name"
label="Full Name"
minlength="3"
maxlength="50"
placeholder="Min 3, max 50 characters"
required
></zv-input>
<button type="submit">Submit</button>
</form>
`,
})
export class EmailFormComponent {
email = signal('');
name = signal('');
submit() {
if (this.email() && this.name()) {
console.log('Form submitted:', { email: this.email(), name: this.name() });
}
}
}Limitations of Template Forms
- ❌ No centralized validation logic
- ❌ No form-level validity state
- ❌ Limited control over async validation
- ❌ Hard to test validation logic
- ❌ No touched/dirty state tracking at form level
When to upgrade: If your form grows beyond 3-4 fields or needs validation, consider Reactive Forms or Signal Forms.
Reactive Forms
Reactive Forms use FormControl, FormGroup, and the Angular FormBuilder. They provide more control and better integration with Angular's forms ecosystem.
When to Use Reactive Forms
- Medium to large applications
- Complex form validation requirements
- Need to validate at the form level
- Integrating with existing Angular ecosystem
- Need programmatic form manipulation
Basic Reactive Form Example
import { Component } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { ZvInput, ZvEmail, ZvPassword } from '@zellvora/ng-input';
@Component({
selector: 'app-login-reactive',
standalone: true,
imports: [ReactiveFormsModule, ZvInput, ZvEmail, ZvPassword],
template: `
<form [formGroup]="form" (ngSubmit)="login()">
<!-- Email field bound via formControlName -->
<zv-email
formControlName="email"
label="Email Address"
placeholder="[email protected]"
></zv-email>
<!-- Password field bound via formControlName -->
<zv-password
formControlName="password"
label="Password"
placeholder="Enter your password"
></zv-password>
<!-- Show validation errors -->
<div *ngIf="emailControl?.errors && emailControl?.touched" class="error">
<p *ngIf="emailControl?.errors['required']">Email is required</p>
<p *ngIf="emailControl?.errors['email']">Invalid email format</p>
</div>
<!-- Show form validity -->
<div class="form-status">
<p>Form Valid: {{ form.valid }}</p>
<p>Email Touched: {{ emailControl?.touched }}</p>
<p>Email Value: {{ emailControl?.value }}</p>
</div>
<!-- Submit button -->
<button type="submit" [disabled]="form.invalid">
Login
</button>
</form>
`,
styles: [`
form {
max-width: 400px;
margin: 2rem auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.error {
color: #dc2626;
font-size: 0.875rem;
margin-top: 0.5rem;
}
.form-status {
background: #f3f4f6;
padding: 1rem;
border-radius: 4px;
font-size: 0.875rem;
}
button {
padding: 0.75rem;
background: #2563eb;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:disabled {
background: #d1d5db;
cursor: not-allowed;
}
`],
})
export class LoginReactiveComponent {
form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
constructor(private fb: FormBuilder) {}
get emailControl() {
return this.form.get('email');
}
login() {
if (this.form.valid) {
console.log('Login with:', this.form.value);
// Call your API
} else {
// Mark all fields as touched to show errors
Object.keys(this.form.controls).forEach(key => {
this.form.get(key)?.markAsTouched();
});
}
}
}How Reactive Forms Work:
FormBuildercreates aFormGroupwith named controls- Each control has validators (required, email, minLength, etc.)
formControlNamebinds the input to the form control- Access the control's state:
value,errors,valid,touched, etc. - Form validity = all controls valid
Multiple Fields in Reactive Form
import { Component } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import {
ZvInput,
ZvEmail,
ZvPassword,
ZvNumber,
ZvSelect,
ZvCheckbox,
} from '@zellvora/ng-input';
@Component({
selector: 'app-registration-reactive',
standalone: true,
imports: [
ReactiveFormsModule,
ZvInput,
ZvEmail,
ZvPassword,
ZvNumber,
ZvSelect,
ZvCheckbox,
],
template: `
<form [formGroup]="form" (ngSubmit)="register()">
<!-- First Name -->
<div class="form-field">
<zv-input
formControlName="firstName"
label="First Name"
placeholder="John"
required
></zv-input>
<small *ngIf="firstNameControl?.errors && firstNameControl?.touched">
{{ getErrorMessage('firstName') }}
</small>
</div>
<!-- Email -->
<div class="form-field">
<zv-email
formControlName="email"
label="Email"
required
></zv-email>
<small *ngIf="emailControl?.errors && emailControl?.touched">
{{ getErrorMessage('email') }}
</small>
</div>
<!-- Password -->
<div class="form-field">
<zv-password
formControlName="password"
label="Password"
[hint]="'Min 8 chars, must include uppercase and numbers'"
required
></zv-password>
<small *ngIf="passwordControl?.errors && passwordControl?.touched">
{{ getErrorMessage('password') }}
</small>
</div>
<!-- Age -->
<div class="form-field">
<zv-number
formControlName="age"
label="Age"
[min]="18"
[max]="100"
></zv-number>
<small *ngIf="ageControl?.errors && ageControl?.touched">
{{ getErrorMessage('age') }}
</small>
</div>
<!-- Country -->
<div class="form-field">
<zv-select
formControlName="country"
label="Country"
[items]="countries"
required
></zv-select>
<small *ngIf="countryControl?.errors && countryControl?.touched">
{{ getErrorMessage('country') }}
</small>
</div>
<!-- Terms Checkbox -->
<div class="form-field">
<zv-checkbox
formControlName="agreeTerms"
label="I agree to terms and conditions"
required
></zv-checkbox>
<small *ngIf="termsControl?.errors && termsControl?.touched">
{{ getErrorMessage('agreeTerms') }}
</small>
</div>
<!-- Form Status -->
<div class="form-status">
<p><strong>Form Valid:</strong> {{ form.valid }}</p>
<p><strong>Form Touched:</strong> {{ form.touched }}</p>
<p><strong>Form Dirty:</strong> {{ form.dirty }}</p>
</div>
<!-- Buttons -->
<div class="form-actions">
<button type="submit" [disabled]="form.invalid">Register</button>
<button type="button" (click)="reset()">Reset</button>
</div>
<!-- Display submitted data -->
<div *ngIf="submitted" class="submitted-data">
<h3>Registered Data:</h3>
<pre>{{ form.value | json }}</pre>
</div>
</form>
`,
styles: [`
form {
max-width: 500px;
margin: 2rem auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
small {
color: #dc2626;
font-size: 0.875rem;
}
.form-status {
background: #f3f4f6;
padding: 1rem;
border-radius: 4px;
font-size: 0.875rem;
}
.form-actions {
display: flex;
gap: 1rem;
}
button {
flex: 1;
padding: 0.75rem;
border: none;
border-radius: 4px;
cursor: pointer;
}
button[type="submit"] {
background: #2563eb;
color: white;
}
button[type="submit"]:disabled {
background: #d1d5db;
}
button[type="button"] {
background: #e5e7eb;
color: #333;
}
.submitted-data {
background: #dcfce7;
padding: 1rem;
border-radius: 4px;
margin-top: 1rem;
}
`],
})
export class RegistrationReactiveComponent {
countries = [
{ label: 'India', value: 'IN' },
{ label: 'USA', value: 'US' },
{ label: 'Canada', value: 'CA' },
];
form = this.fb.group({
firstName: ['', [Validators.required, Validators.minLength(2)]],
email: ['', [Validators.required, Validators.email]],
password: [
'',
[
Validators.required,
Validators.minLength(8),
// Custom regex for password strength
Validators.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/),
],
],
age: [null, [Validators.required, Validators.min(18)]],
country: ['', [Validators.required]],
agreeTerms: [false, [Validators.requiredTrue]],
});
submitted = false;
constructor(private fb: FormBuilder) {}
get firstNameControl() {
return this.form.get('firstName');
}
get emailControl() {
return this.form.get('email');
}
get passwordControl() {
return this.form.get('password');
}
get ageControl() {
return this.form.get('age');
}
get countryControl() {
return this.form.get('country');
}
get termsControl() {
return this.form.get('agreeTerms');
}
getErrorMessage(fieldName: string): string {
const control = this.form.get(fieldName);
if (!control?.errors) return '';
const errors = control.errors;
if (errors['required']) return `${fieldName} is required`;
if (errors['email']) return 'Invalid email format';
if (errors['minLength']) return `Minimum length: ${errors['minLength'].requiredLength}`;
if (errors['min']) return `Minimum value: ${errors['min'].min}`;
if (errors['pattern']) return 'Password must have uppercase, lowercase, and numbers';
if (errors['requiredTrue']) return 'You must agree to terms';
return 'Invalid input';
}
register() {
if (this.form.valid) {
console.log('Registering:', this.form.value);
this.submitted = true;
// Call your API here
} else {
// Mark all fields as touched to show errors
Object.keys(this.form.controls).forEach(key => {
this.form.get(key)?.markAsTouched();
});
}
}
reset() {
this.form.reset();
this.submitted = false;
}
}Async Validation in Reactive Forms
import { Component } from '@angular/core';
import {
ReactiveFormsModule,
FormBuilder,
Validators,
AbstractControl,
AsyncValidatorFn,
ValidationErrors,
} from '@angular/forms';
import { Observable, of } from 'rxjs';
import { delay, map } from 'rxjs/operators';
import { ZvEmail } from '@zellvora/ng-input';
// Custom async validator to check if email exists
const emailAvailabilityValidator: AsyncValidatorFn = (
control: AbstractControl
): Observable<ValidationErrors | null> => {
if (!control.value) {
return of(null);
}
// Simulate API call
return of(control.value).pipe(
delay(500),
map(email => {
// Simulate checking if email exists
const takenEmails = ['[email protected]', '[email protected]'];
return takenEmails.includes(email) ? { emailTaken: true } : null;
})
);
};
@Component({
selector: 'app-async-validation',
standalone: true,
imports: [ReactiveFormsModule, ZvEmail],
template: `
<form [formGroup]="form">
<zv-email
formControlName="email"
label="Email"
placeholder="Check availability..."
></zv-email>
<!-- Show async validation status -->
<div *ngIf="emailControl?.pending" class="loading">
Checking email availability...
</div>
<div *ngIf="emailControl?.errors?.['emailTaken']" class="error">
This email is already taken
</div>
<div *ngIf="emailControl?.valid && emailControl?.touched" class="success">
Email is available!
</div>
<p>Status: {{ emailControl?.status }}</p>
</form>
`,
styles: [`
.loading {
color: #f59e0b;
font-size: 0.875rem;
margin-top: 0.5rem;
}
.error {
color: #dc2626;
font-size: 0.875rem;
margin-top: 0.5rem;
}
.success {
color: #16a34a;
font-size: 0.875rem;
margin-top: 0.5rem;
}
`],
})
export class AsyncValidationComponent {
form = this.fb.group({
email: [
'',
[Validators.required, Validators.email],
[emailAvailabilityValidator], // Async validators go here
],
});
constructor(private fb: FormBuilder) {}
get emailControl() {
return this.form.get('email');
}
}When to Use Reactive Forms
✅ Use when:
- You need form-level validation
- You need to track touched/dirty state
- You need async validation
- You have complex interdependencies between fields
- You're building a larger application
❌ Avoid when:
- Building a simple 2-3 field form
- You want maximum simplicity
Signal Forms (Recommended)
Signal Forms use the Zellvora Signal Form Engine. This is the recommended approach for enterprise applications.
Why Signal Forms?
| Aspect | Reactive Forms | Signal Forms | |--------|---|---| | Type Safety | Good | Excellent ✓ | | Validation Logic | Form level | Centralized in schema | | Reactivity | Subscriptions | Computed (automatic) | | Learning Curve | Medium | Medium | | Performance | Good | Excellent ✓ | | Testing | Moderate | Easy ✓ |
Understanding the Signal Form Engine
The Signal Form Engine works differently from Reactive Forms:
Traditional Reactive Forms:
┌─────────────┐
│ FormGroup │
├─────────────┤
│ FormControl │ → Subscription → Component Updates
├─────────────┤
│ FormControl │ → Subscription → Component Updates
└─────────────┘
Signal Forms:
┌──────────────┐
│ Model Signal │ (WritableSignal<T>)
├──────────────┤
│ Control 1 │ → Computed Signal → Automatic updates
├──────────────┤
│ Control 2 │ → Computed Signal → Automatic updates
│ (errors) │ → Computed Signal → Automatic updates
│ (valid) │ → Computed Signal → Automatic updates
└──────────────┘Key difference: The model is the source of truth. Controls are just views over the model.
Creating a Signal Form
import { Component, signal } from '@angular/core';
import { createForm, ZvValidators } from '@zellvora/ng-input';
@Component({
selector: 'app-signal-form',
standalone: true,
template: ``,
})
export class SignalFormComponent {
// Step 1: Define the model structure (TypeScript interface)
interface LoginModel {
email: string;
password: string;
rememberMe: boolean;
}
// Step 2: Create the form with initial values and validators
form = createForm<LoginModel>(
// Initial model (these are the starting values)
{
email: '',
password: '',
rememberMe: false,
},
// Validators for each field
{
email: {
validators: [ZvValidators.required, ZvValidators.email],
},
password: {
validators: [ZvValidators.required, ZvValidators.minLength(8)],
},
}
);
// Now you have:
// - form.model: WritableSignal<LoginModel>
// - form.value: Signal<LoginModel> (readonly computed view)
// - form.valid: Signal<boolean>
// - form.errors: Signal<Record<string, any>>
// - form.control('email'): ZvControl<string>
// - form.control('password'): ZvControl<string>
}Basic Signal Form Example
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
createForm,
ZvValidators,
ZvInput,
ZvEmail,
ZvPassword,
} from '@zellvora/ng-input';
@Component({
selector: 'app-login-signal-form',
standalone: true,
imports: [CommonModule, ZvInput, ZvEmail, ZvPassword],
template: `
<form (ngSubmit)="login()">
<!-- Email input -->
<zv-email
[form]="form"
controlName="email"
label="Email Address"
placeholder="[email protected]"
></zv-email>
<!-- Password input -->
<zv-password
[form]="form"
controlName="password"
label="Password"
></zv-password>
<!-- Display form state -->
<div class="form-state">
<p><strong>Email Value:</strong> {{ emailControl.value() }}</p>
<p><strong>Email Errors:</strong> {{ emailControl.errors() | json }}</p>
<p><strong>Email Valid:</strong> {{ emailControl.valid() }}</p>
<p><strong>Email Touched:</strong> {{ emailControl.touched() }}</p>
<p><strong>Form Valid:</strong> {{ form.valid() }}</p>
</div>
<!-- Submit button -->
<button
type="submit"
[disabled]="form.invalid()"
(click)="markAllTouched()"
>
Login
</button>
</form>
`,
styles: [`
form {
max-width: 400px;
margin: 2rem auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-state {
background: #f3f4f6;
padding: 1rem;
border-radius: 4px;
font-size: 0.875rem;
}
button {
padding: 0.75rem;
background: #2563eb;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:disabled {
background: #d1d5db;
cursor: not-allowed;
}
`],
})
export class LoginSignalFormComponent {
// Create form
form = createForm(
{ email: '', password: '' },
{
email: { validators: [ZvValidators.required, ZvValidators.email] },
password: { validators: [ZvValidators.required, ZvValidators.minLength(8)] },
}
);
get emailControl() {
return this.form.control('email');
}
get passwordControl() {
return this.form.control('password');
}
markAllTouched() {
this.emailControl.markTouched(true);
this.passwordControl.markTouched(true);
}
login() {
if (this.form.valid()) {
console.log('Login with:', this.form.value());
}
}
}Key points:
[form]="form"— Pass the form object to the controlcontrolName="email"— Name of the fieldform.control('email')— Get a control to access stateform.valid(),form.value(),form.errors()— All signals, no subscriptions
Multiple Fields in Signal Form
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
createForm,
ZvValidators,
ZvInput,
ZvEmail,
ZvPassword,
ZvNumber,
ZvSelect,
ZvCheckbox,
} from '@zellvora/ng-input';
interface RegistrationData {
firstName: string;
email: string;
password: string;
age: number | null;
country: string;
agreeTerms: boolean;
}
@Component({
selector: 'app-registration-signal',
standalone: true,
imports: [
CommonModule,
ZvInput,
ZvEmail,
ZvPassword,
ZvNumber,
ZvSelect,
ZvCheckbox,
],
template: `
<form (ngSubmit)="register()">
<!-- First Name -->
<div class="form-group">
<zv-input
[form]="form"
controlName="firstName"
label="First Name"
placeholder="John"
></zv-input>
<div *ngIf="firstNameControl.errors()" class="error">
First name is required (min 2 chars)
</div>
</div>
<!-- Email -->
<div class="form-group">
<zv-email
[form]="form"
controlName="email"
label="Email"
></zv-email>
<div *ngIf="emailControl.errors()" class="error">
Valid email is required
</div>
</div>
<!-- Password -->
<div class="form-group">
<zv-password
[form]="form"
controlName="password"
label="Password"
hint="Min 8 characters"
></zv-password>
<div *ngIf="passwordControl.errors()" class="error">
Password must be at least 8 characters
</div>
</div>
<!-- Age -->
<div class="form-group">
<zv-number
[form]="form"
controlName="age"
label="Age"
[min]="18"
[max]="120"
></zv-number>
<div *ngIf="ageControl.errors()" class="error">
Age must be 18 or older
</div>
</div>
<!-- Country -->
<div class="form-group">
<zv-select
[form]="form"
controlName="country"
label="Country"
[items]="countries"
></zv-select>
<div *ngIf="countryControl.errors()" class="error">
Country is required
</div>
</div>
<!-- Terms Checkbox -->
<div class="form-group">
<zv-checkbox
[form]="form"
controlName="agreeTerms"
label="I agree to terms and conditions"
></zv-checkbox>
<div *ngIf="termsControl.errors()" class="error">
You must agree to terms
</div>
</div>
<!-- Form Status Panel -->
<div class="status-panel">
<h3>Form Status</h3>
<p><strong>Valid:</strong> {{ form.valid() }}</p>
<p><strong>Invalid:</strong> {{ form.invalid() }}</p>
<p><strong>Touched:</strong> {{ form.touched() }}</p>
<p><strong>Dirty:</strong> {{ form.dirty() }}</p>
<p><strong>Pending:</strong> {{ form.pending() }}</p>
</div>
<!-- Form Data Display -->
<div class="data-panel">
<h3>Form Data</h3>
<pre>{{ form.value() | json }}</pre>
</div>
<!-- Buttons -->
<div class="actions">
<button type="submit" [disabled]="form.invalid()">
Register
</button>
<button type="button" (click)="reset()">
Reset
</button>
</div>
<!-- Submitted confirmation -->
<div *ngIf="submitted" class="success">
<h3>Successfully Registered!</h3>
<pre>{{ submittedData | json }}</pre>
</div>
</form>
`,
styles: [`
form {
max-width: 600px;
margin: 2rem auto;
padding: 2rem;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.form-group {
margin-bottom: 1.5rem;
}
.error {
color: #dc2626;
font-size: 0.875rem;
margin-top: 0.5rem;
}
.status-panel {
background: #f3f4f6;
padding: 1rem;
border-radius: 4px;
margin: 1.5rem 0;
font-size: 0.875rem;
}
.status-panel h3 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
}
.status-panel p {
margin: 0.25rem 0;
}
.data-panel {
background: #f0f9ff;
padding: 1rem;
border-radius: 4px;
margin: 1.5rem 0;
max-height: 300px;
overflow: auto;
}
.data-panel pre {
margin: 0;
font-size: 0.75rem;
}
.actions {
display: flex;
gap: 1rem;
margin-top: 1.5rem;
}
button {
flex: 1;
padding: 0.75rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
}
button[type="submit"] {
background: #2563eb;
color: white;
}
button[type="submit"]:disabled {
background: #d1d5db;
cursor: not-allowed;
}
button[type="button"] {
background: #e5e7eb;
color: #333;
}
.success {
background: #dcfce7;
border: 2px solid #16a34a;
padding: 1rem;
border-radius: 4px;
margin-top: 2rem;
}
.success h3 {
margin: 0 0 1rem 0;
color: #16a34a;
}
`],
})
export class RegistrationSignalComponent {
countries = [
{ label: 'India', value: 'IN' },
{ label: 'USA', value: 'US' },
{ label: 'Canada', value: 'CA' },
];
// Create form with initial values and validators
form = createForm<RegistrationData>(
{
firstName: '',
email: '',
password: '',
age: null,
country: '',
agreeTerms: false,
},
{
firstName: { validators: [ZvValidators.required, ZvValidators.minLength(2)] },
email: { validators: [ZvValidators.required, ZvValidators.email] },
password: { validators: [ZvValidators.required, ZvValidators.minLength(8)] },
age: { validators: [ZvValidators.required, ZvValidators.min(18)] },
country: { validators: [ZvValidators.required] },
agreeTerms: { validators: [ZvValidators.required] },
}
);
submitted = false;
submittedData: any = null;
// Getters for controls
get firstNameControl() {
return this.form.control('firstName');
}
get emailControl() {
return this.form.control('email');
}
get passwordControl() {
return this.form.control('password');
}
get ageControl() {
return this.form.control('age');
}
get countryControl() {
return this.form.control('country');
}
get termsControl() {
return this.form.control('agreeTerms');
}
register() {
this.form.markAllTouched();
if (this.form.valid()) {
console.log('Registering:', this.form.value());
this.submittedData = this.form.value();
this.submitted = true;
}
}
reset() {
this.form.reset();
this.submitted = false;
this.submittedData = null;
}
}Advanced: Async Validators in Signal Forms
import { Component } from '@angular/core';
import { createForm, ZvValidators, ZvEmail, type ZvAsyncValidator } from '@zellvora/ng-input';
// Async validator for checking email availability
const emailAvailabilityValidator: ZvAsyncValidator<string> = async (value) => {
if (!value) return null;
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 500));
// Check if email is taken
const takenEmails = ['[email protected]', '[email protected]'];
return takenEmails.includes(value) ? { emailTaken: true } : null;
};
@Component({
selector: 'app-async-email',
standalone: true,
imports: [ZvEmail],
template: `
<zv-email
[form]="form"
controlName="email"
label="Email"
></zv-email>
<div *ngIf="emailControl.pending()" class="loading">
⏳ Checking availability...
</div>
<div *ngIf="emailControl.errors()?.['emailTaken']" class="error">
❌ Email already taken
</div>
<div *ngIf="emailControl.valid() && emailControl.touched()" class="success">
✓ Email available!
</div>
<p>Status: {{ emailControl.status() }}</p>
`,
styles: [`
.loading { color: #f59e0b; }
.error { color: #dc2626; }
.success { color: #16a34a; }
`],
})
export class AsyncEmailComponent {
form = createForm(
{ email: '' },
{
email: {
validators: [ZvValidators.required, ZvValidators.email],
asyncValidators: [emailAvailabilityValidator],
},
}
);
get emailControl() {
return this.form.control('email');
}
}Accessing and Modifying Form State
// Get form values
form.value(); // Returns current model state
// Update model
form.model.set({ name: 'John', email: '[email protected]' });
// Partial update
form.patch({ name: 'Jane' });
// Reset to initial values
form.reset();
// Reset with new values
form.reset({ name: 'John', email: '' });
// Get control
const nameControl = form.control('name');
// Control state (all signals)
nameControl.value(); // Current value
nameControl.errors(); // { [key]: true } or null
nameControl.valid(); // Is valid?
nameControl.invalid(); // Is invalid?
nameControl.touched(); // User touched?
nameControl.dirty(); // User changed?
nameControl.pending(); // Async validation running?
nameControl.disabled(); // Is disabled?
nameControl.status(); // 'VALID' | 'INVALID' | 'PENDING'
// Modify control
nameControl.setValue('John'); // Set value
nameControl.update(v => v.toUpperCase()); // Update with function
nameControl.setDisabled(true); // Disable
nameControl.markTouched(true); // Mark touched
nameControl.markDirty(true); // Mark dirty
nameControl.reset(); // Reset controlComparison: Signal Forms vs Reactive Forms vs Template Forms
| Feature | Template Forms | Reactive Forms | Signal Forms | |---------|---|---|---| | Setup Complexity | Very Simple | Medium | Medium | | Type Safety | Low | Good | Excellent ✓ | | Form Validation | ❌ | ✓ | ✓ | | Async Validators | ❌ | ✓ | ✓ | | Touched/Dirty Tracking | ❌ | ✓ | ✓ | | Reactivity | Good | Good | Excellent ✓ | | No Subscriptions | ✓ | ❌ | ✓ | | Learning Curve | Easiest | Medium | Medium | | Enterprise Ready | ❌ | ✓ | ✓✓ |
15 Form Controls
1. Text Input (zv-input)
import { Component } from '@angular/core';
import { createForm, ZvInput, ZvValidators } from '@zellvora/ng-input';
@Component({
selector: 'app-text-input',
standalone: true,
imports: [ZvInput],
template: `
<zv-input
[form]="form"
controlName="name"
label="Full Name"
placeholder="John Doe"
hint="Your legal name"
[required]="true"
[clearable]="true"
minlength="3"
maxlength="50"
prefixIcon="person"
suffixIcon="check"
></zv-input>
`,
})
export class TextInputComponent {
form = createForm(
{ name: '' },
{ name: { validators: [ZvValidators.required, ZvValidators.minLength(3)] } }
);
}2. Email Input (zv-email)
<zv-email
[form]="form"
controlName="email"
label="Email Address"
placeholder="[email protected]"
hint="We'll never share your email"
[required]="true"
[clearable]="true"
></zv-email>Automatically validates email format.
3. Password Input (zv-password)
<zv-password
[form]="form"
controlName="password"
label="Password"
hint="Min 8 chars, mixed case, numbers"
[required]="true"
minlength="8"
></zv-password>Toggles visibility icon. Shows/hides password.
4. Number Input (zv-number)
<zv-number
[form]="form"
controlName="age"
label="Age"
[min]="18"
[max]="120"
[required]="true"
></zv-number>Props: label, placeholder, required, disabled, min, max, step, clearable
5. Textarea (zv-textarea)
<zv-textarea
[form]="form"
controlName="bio"
label="Bio"
placeholder="Tell us about yourself..."
[rows]="4"
maxlength="500"
></zv-textarea>6. Datepicker (zv-datepicker)
<zv-datepicker
[form]="form"
controlName="birthDate"
label="Date of Birth"
[required]="true"
[min]="minDate"
[max]="maxDate"
></zv-datepicker>7. Timepicker (zv-timepicker)
<zv-timepicker
[form]="form"
controlName="meetingTime"
label="Meeting Time"
></zv-timepicker>8. Select (Single) (zv-select)
<zv-select
[form]="form"
controlName="country"
label="Country"
[items]="countries"
[required]="true"
[clearable]="true"
></zv-select>Items format:
countries = [
{ label: 'India', value: 'IN' },
{ label: 'USA', value: 'US' },
];9. Multiselect (zv-multiselect)
<zv-multiselect
[form]="form"
controlName="tags"
label="Select Tags"
[items]="availableTags"
></zv-multiselect>Returns an array of selected values.
10. Checkbox (zv-checkbox)
<zv-checkbox
[form]="form"
controlName="agreeTerms"
label="I agree to terms"
[required]="true"
></zv-checkbox>11. Radio (zv-radio)
<zv-radio
[form]="form"
controlName="gender"
label="Gender"
[items]="[
{ label: 'Male', value: 'M' },
{ label: 'Female', value: 'F' },
{ label: 'Other', value: 'O' }
]"
></zv-radio>12. Switch (zv-switch)
<zv-switch
[form]="form"
controlName="notifications"
label="Enable notifications"
></zv-switch>13. PIN Input (zv-pin)
<zv-pin
[form]="form"
controlName="otp"
label="Enter OTP"
[length]="6"
></zv-pin>14. File Upload (zv-upload)
<zv-upload
[form]="form"
controlName="document"
label="Upload Document"
accept=".pdf,.doc,.docx"
[multiple]="false"
></zv-upload>15. Field Shell (zv-field-shell)
Wrapper component for consistent styling:
<zv-field-shell
[for]="'input-id'"
[label]="'Label'"
[hint]="'Helper text'"
[errorText]="'Error message'"
>
<input id="input-id" />
</zv-field-shell>Validation System
Built-in Validators Reference
import { ZvValidators } from '@zellvora/ng-input';
// Required
ZvValidators.required;
// Text patterns
ZvValidators.email;
ZvValidators.phone;
ZvValidators.mobile; // India
ZvValidators.url;
ZvValidators.password; // Strong password
// India-specific
ZvValidators.pan;
ZvValidators.gst;
ZvValidators.aadhaar;
ZvValidators.passport;
ZvValidators.drivingLicense;
ZvValidators.pincode;
ZvValidators.ifsc;
// Finance
ZvValidators.cvv;
ZvValidators.swift;
ZvValidators.iban;
// Date/Time
ZvValidators.date;
ZvValidators.time;
// Range
ZvValidators.min(n);
ZvValidators.max(n);
ZvValidators.minLength(n);
ZvValidators.maxLength(n);
// Pattern
ZvValidators.pattern(/regex/);
ZvValidators.regex(/regex/);Custom Validators
import { type ZvValidator } from '@zellvora/ng-input';
// Sync validator
const noSpacesValidator: ZvValidator<string> = (value, model) => {
if (!value) return null;
return /\s/.test(value) ? { noSpaces: true } : null;
};
// Use in form
form = createForm(
{ username: '' },
{ username: { validators: [noSpacesValidator] } }
);Advanced Topics
Styling with CSS Variables
:root {
/* Colors */
--zv-primary: #2563eb;
--zv-error: #dc2626;
--zv-success: #16a34a;
--zv-border: #d1d5db;
--zv-bg: #ffffff;
--zv-hover: #f3f4f6;
/* Sizing */
--zv-radius: 0.5rem;
--zv-spacing: 1rem;
/* Typography */
--zv-font-size: 1rem;
--zv-font-weight: 400;
}Creating Custom Controls
Extend SignalControlAccessor to create custom controls:
import { Component } from '@angular/core';
import { SignalControlAccessor } from '@zellvora/ng-input';
@Component({
selector: 'my-slider',
standalone: true,
template: `
<input
type="range"
[value]="current()"
(input)="commit($event.target.value)"
/>
`,
})
export class MySliderControl extends SignalControlAccessor<number> {
readonly controlType = 'slider';
}Best Practices Checklist
- ✓ Use Signal Forms for new apps
- ✓ Use Reactive Forms for integrating with existing Angular code
- ✓ Validate early (in the schema/form definition)
- ✓ Mark touched on blur automatically
- ✓ Show errors only after user interaction
- ✓ Use proper field types (email, password, number, etc.)
- ✓ Test validation logic independently
- ✓ Handle async operations gracefully
- ✓ Make forms accessible (labels, hints, ARIA)
Browser Support
- Angular 21+
- Chrome, Firefox, Safari, Edge (latest 2 versions)
- Mobile browsers (iOS Safari, Chrome Mobile)
License
MIT
