angular-forms

安装量: 3.2K
排名: #690

安装

npx skills add https://github.com/analogjs/angular-skills --skill angular-forms

Angular Signal Forms

Build type-safe, reactive forms using Angular's Signal Forms API. Signal Forms provide automatic two-way binding, schema-based validation, and reactive field state.

Note: Signal Forms are experimental in Angular v21. For production apps requiring stability, see references/form-patterns.md for Reactive Forms patterns.

Basic Setup import { Component, signal } from '@angular/core'; import { form, FormField, required, email } from '@angular/forms/signals';

interface LoginData { email: string; password: string; }

@Component({ selector: 'app-login', imports: [FormField], template: `

@if (loginForm.email().touched() && loginForm.email().invalid()) {

{{ loginForm.email().errors()[0].message }}

}

  <label>
    Password
    <input type="password" [formField]="loginForm.password" />
  </label>
  @if (loginForm.password().touched() && loginForm.password().invalid()) {
    <p class="error">{{ loginForm.password().errors()[0].message }}</p>
  }

  <button type="submit" [disabled]="loginForm().invalid()">Login</button>
</form>

`, }) export class LoginComponent { // Form model - a writable signal loginModel = signal({ email: '', password: '', });

// Create form with validation schema loginForm = form(this.loginModel, (schemaPath) => { required(schemaPath.email, { message: 'Email is required' }); email(schemaPath.email, { message: 'Enter a valid email address' }); required(schemaPath.password, { message: 'Password is required' }); });

onSubmit(event: Event) { event.preventDefault(); if (this.loginForm().valid()) { const credentials = this.loginModel(); console.log('Submitting:', credentials); } } }

Form Models

Form models are writable signals that serve as the single source of truth:

// Define interface for type safety interface UserProfile { name: string; email: string; age: number | null; preferences: { newsletter: boolean; theme: 'light' | 'dark'; }; }

// Create model signal with initial values const userModel = signal({ name: '', email: '', age: null, preferences: { newsletter: false, theme: 'light', }, });

// Create form from model const userForm = form(userModel);

// Access nested fields via dot notation userForm.name // FieldTree userForm.preferences.theme // FieldTree<'light' | 'dark'>

Reading Values // Read entire model const data = this.userModel();

// Read field value via field state const name = this.userForm.name().value(); const theme = this.userForm.preferences.theme().value();

Updating Values // Replace entire model this.userModel.set({ name: 'Alice', email: 'alice@example.com', age: 30, preferences: { newsletter: true, theme: 'dark' }, });

// Update single field this.userForm.name().value.set('Bob'); this.userForm.age().value.update(age => (age ?? 0) + 1);

Field State

Each field provides reactive signals for validation, interaction, and availability:

const emailField = this.form.email();

// Validation state emailField.valid() // true if passes all validation emailField.invalid() // true if has validation errors emailField.errors() // array of error objects emailField.pending() // true if async validation in progress

// Interaction state emailField.touched() // true after focus + blur emailField.dirty() // true after user modification

// Availability state emailField.disabled() // true if field is disabled emailField.hidden() // true if field should be hidden emailField.readonly() // true if field is readonly

// Value emailField.value() // current field value (signal)

Form-Level State

The form itself is also a field with aggregated state:

// Form is valid when all interactive fields are valid this.form().valid()

// Form is touched when any field is touched this.form().touched()

// Form is dirty when any field is modified this.form().dirty()

Validation Built-in Validators import { form, required, email, min, max, minLength, maxLength, pattern } from '@angular/forms/signals';

const userForm = form(this.userModel, (schemaPath) => { // Required field required(schemaPath.name, { message: 'Name is required' });

// Email format email(schemaPath.email, { message: 'Invalid email' });

// Numeric range min(schemaPath.age, 18, { message: 'Must be 18+' }); max(schemaPath.age, 120, { message: 'Invalid age' });

// String/array length minLength(schemaPath.password, 8, { message: 'Min 8 characters' }); maxLength(schemaPath.bio, 500, { message: 'Max 500 characters' });

// Regex pattern pattern(schemaPath.phone, /^\d{3}-\d{3}-\d{4}$/, { message: 'Format: 555-123-4567', }); });

Conditional Validation const orderForm = form(this.orderModel, (schemaPath) => { required(schemaPath.promoCode, { message: 'Promo code required for discounts', when: ({ valueOf }) => valueOf(schemaPath.applyDiscount), }); });

Custom Validators import { validate } from '@angular/forms/signals';

const signupForm = form(this.signupModel, (schemaPath) => { // Custom validation logic validate(schemaPath.username, ({ value }) => { if (value().includes(' ')) { return { kind: 'noSpaces', message: 'Username cannot contain spaces' }; } return null; }); });

Cross-Field Validation const passwordForm = form(this.passwordModel, (schemaPath) => { required(schemaPath.password); required(schemaPath.confirmPassword);

// Compare fields validate(schemaPath.confirmPassword, ({ value, valueOf }) => { if (value() !== valueOf(schemaPath.password)) { return { kind: 'mismatch', message: 'Passwords do not match' }; } return null; }); });

Async Validation import { validateHttp } from '@angular/forms/signals';

const signupForm = form(this.signupModel, (schemaPath) => { validateHttp(schemaPath.username, { request: ({ value }) => /api/check-username?u=${value()}, onSuccess: (response: { taken: boolean }) => { if (response.taken) { return { kind: 'taken', message: 'Username already taken' }; } return null; }, onError: () => ({ kind: 'networkError', message: 'Could not verify username', }), }); });

Conditional Fields Hidden Fields import { hidden } from '@angular/forms/signals';

const profileForm = form(this.profileModel, (schemaPath) => { hidden(schemaPath.publicUrl, ({ valueOf }) => !valueOf(schemaPath.isPublic)); });

@if (!profileForm.publicUrl().hidden()) { }

Disabled Fields import { disabled } from '@angular/forms/signals';

const orderForm = form(this.orderModel, (schemaPath) => { disabled(schemaPath.couponCode, ({ valueOf }) => valueOf(schemaPath.total) < 50); });

Readonly Fields import { readonly } from '@angular/forms/signals';

const accountForm = form(this.accountModel, (schemaPath) => { readonly(schemaPath.username); // Always readonly });

Form Submission import { submit } from '@angular/forms/signals';

@Component({ template: <form (submit)="onSubmit($event)"> <input [formField]="form.email" /> <input [formField]="form.password" /> <button type="submit" [disabled]="form().invalid()">Submit</button> </form>, }) export class LoginComponent { model = signal({ email: '', password: '' }); form = form(this.model, (schemaPath) => { required(schemaPath.email); required(schemaPath.password); });

onSubmit(event: Event) { event.preventDefault();

// submit() marks all fields touched and runs callback if valid
submit(this.form, async () => {
  await this.authService.login(this.model());
});

} }

Arrays and Dynamic Fields interface Order { items: Array<{ product: string; quantity: number }>; }

@Component({ template: @for (item of orderForm.items; track $index; let i = $index) { <div> <input [formField]="item.product" placeholder="Product" /> <input [formField]="item.quantity" type="number" /> <button type="button" (click)="removeItem(i)">Remove</button> </div> } <button type="button" (click)="addItem()">Add Item</button>, }) export class OrderComponent { orderModel = signal({ items: [{ product: '', quantity: 1 }], });

orderForm = form(this.orderModel, (schemaPath) => { applyEach(schemaPath.items, (item) => { required(item.product, { message: 'Product required' }); min(item.quantity, 1, { message: 'Min quantity is 1' }); }); });

addItem() { this.orderModel.update(m => ({ ...m, items: [...m.items, { product: '', quantity: 1 }], })); }

removeItem(index: number) { this.orderModel.update(m => ({ ...m, items: m.items.filter((_, i) => i !== index), })); } }

Displaying Errors

@if (form.email().touched() && form.email().invalid()) {

    @for (error of form.email().errors(); track error) {
  • {{ error.message }}
  • }

}

@if (form.email().pending()) { Validating... }

Styling Based on State

Reset Form async onSubmit() { if (!this.form().valid()) return;

await this.api.submit(this.model());

// Clear interaction state this.form().reset();

// Clear values this.model.set({ email: '', password: '' }); }

For Reactive Forms patterns (production-stable), see references/form-patterns.md.

返回排行榜