npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

  1. Installation & Setup
  2. Core Concepts
  3. Three Binding Approaches
  4. 15 Form Controls
  5. Validation System
  6. Advanced Topics

Installation & Setup

Step 1: Install Package

npm install @zellvora/ng-input

Step 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:

  • FormBuilder to create forms
  • @Input / @Output decorators for data flow
  • Subscriptions to track changes
  • Manual change detection

Zellvora's Signal-First approach:

  • createForm() function to define forms
  • input(), 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:

  1. email and password are signals (or plain values)
  2. [(ngModel)]="email" creates two-way binding
  3. When user types → signal updates
  4. When signal updates → input value updates
  5. 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:

  1. FormBuilder creates a FormGroup with named controls
  2. Each control has validators (required, email, minLength, etc.)
  3. formControlName binds the input to the form control
  4. Access the control's state: value, errors, valid, touched, etc.
  5. 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 control
  • controlName="email" — Name of the field
  • form.control('email') — Get a control to access state
  • form.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 control

Comparison: 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