angular-forms by analogjs/angular-skills
npx skills add https://github.com/analogjs/angular-skills --skill angular-forms使用 Angular 的 Signal Forms API 构建类型安全、响应式的表单。Signal Forms 提供自动双向绑定、基于模式的验证和响应式字段状态。
注意: Signal Forms 在 Angular v21 中处于实验性阶段。对于需要稳定性的生产应用程序,请参阅 references/form-patterns.md 了解响应式表单模式。
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: `
<form (submit)="onSubmit($event)">
<label>
邮箱
<input type="email" [formField]="loginForm.email" />
</label>
@if (loginForm.email().touched() && loginForm.email().invalid()) {
<p class="error">{{ loginForm.email().errors()[0].message }}</p>
}
<label>
密码
<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()">登录</button>
</form>
`,
})
export class Login {
// 表单模型 - 一个可写的 signal
loginModel = signal<LoginData>({
email: '',
password: '',
});
// 使用验证模式创建表单
loginForm = form(this.loginModel, (schemaPath) => {
required(schemaPath.email, { message: '邮箱为必填项' });
email(schemaPath.email, { message: '请输入有效的邮箱地址' });
required(schemaPath.password, { message: '密码为必填项' });
});
onSubmit(event: Event) {
event.preventDefault();
if (this.loginForm().valid()) {
const credentials = this.loginModel();
console.log('提交:', credentials);
}
}
}
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
表单模型是可写的 signal,作为单一事实来源:
// 为类型安全定义接口
interface UserProfile {
name: string;
email: string;
age: number | null;
preferences: {
newsletter: boolean;
theme: 'light' | 'dark';
};
}
// 使用初始值创建模型 signal
const userModel = signal<UserProfile>({
name: '',
email: '',
age: null,
preferences: {
newsletter: false,
theme: 'light',
},
});
// 从模型创建表单
const userForm = form(userModel);
// 通过点表示法访问嵌套字段
userForm.name // FieldTree<string>
userForm.preferences.theme // FieldTree<'light' | 'dark'>
// 读取整个模型
const data = this.userModel();
// 通过字段状态读取字段值
const name = this.userForm.name().value();
const theme = this.userForm.preferences.theme().value();
// 替换整个模型
this.userModel.set({
name: 'Alice',
email: 'alice@example.com',
age: 30,
preferences: { newsletter: true, theme: 'dark' },
});
// 更新单个字段
this.userForm.name().value.set('Bob');
this.userForm.age().value.update(age => (age ?? 0) + 1);
每个字段都为验证、交互和可用性提供响应式 signal:
const emailField = this.form.email();
// 验证状态
emailField.valid() // 如果通过所有验证则为 true
emailField.invalid() // 如果有验证错误则为 true
emailField.errors() // 错误对象数组
emailField.pending() // 如果异步验证正在进行则为 true
// 交互状态
emailField.touched() // 在焦点+失焦后为 true
emailField.dirty() // 在用户修改后为 true
// 可用性状态
emailField.disabled() // 如果字段被禁用则为 true
emailField.hidden() // 如果字段应该隐藏则为 true
emailField.readonly() // 如果字段为只读则为 true
// 值
emailField.value() // 当前字段值 (signal)
表单本身也是一个具有聚合状态的字段:
// 当所有交互字段都有效时,表单有效
this.form().valid()
// 当任何字段被触摸时,表单被触摸
this.form().touched()
// 当任何字段被修改时,表单变脏
this.form().dirty()
import {
form, required, email, min, max,
minLength, maxLength, pattern
} from '@angular/forms/signals';
const userForm = form(this.userModel, (schemaPath) => {
// 必填字段
required(schemaPath.name, { message: '姓名为必填项' });
// 邮箱格式
email(schemaPath.email, { message: '无效的邮箱' });
// 数值范围
min(schemaPath.age, 18, { message: '必须年满18岁' });
max(schemaPath.age, 120, { message: '无效的年龄' });
// 字符串/数组长度
minLength(schemaPath.password, 8, { message: '最少8个字符' });
maxLength(schemaPath.bio, 500, { message: '最多500个字符' });
// 正则表达式模式
pattern(schemaPath.phone, /^\d{3}-\d{3}-\d{4}$/, {
message: '格式: 555-123-4567',
});
});
const orderForm = form(this.orderModel, (schemaPath) => {
required(schemaPath.promoCode, {
message: '折扣需要优惠码',
when: ({ valueOf }) => valueOf(schemaPath.applyDiscount),
});
});
import { validate } from '@angular/forms/signals';
const signupForm = form(this.signupModel, (schemaPath) => {
// 自定义验证逻辑
validate(schemaPath.username, ({ value }) => {
if (value().includes(' ')) {
return { kind: 'noSpaces', message: '用户名不能包含空格' };
}
return null;
});
});
const passwordForm = form(this.passwordModel, (schemaPath) => {
required(schemaPath.password);
required(schemaPath.confirmPassword);
// 比较字段
validate(schemaPath.confirmPassword, ({ value, valueOf }) => {
if (value() !== valueOf(schemaPath.password)) {
return { kind: 'mismatch', message: '密码不匹配' };
}
return null;
});
});
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: '用户名已被占用' };
}
return null;
},
onError: () => ({
kind: 'networkError',
message: '无法验证用户名',
}),
});
});
import { hidden } from '@angular/forms/signals';
const profileForm = form(this.profileModel, (schemaPath) => {
hidden(schemaPath.publicUrl, ({ valueOf }) => !valueOf(schemaPath.isPublic));
});
@if (!profileForm.publicUrl().hidden()) {
<input [formField]="profileForm.publicUrl" />
}
import { disabled } from '@angular/forms/signals';
const orderForm = form(this.orderModel, (schemaPath) => {
disabled(schemaPath.couponCode, ({ valueOf }) => valueOf(schemaPath.total) < 50);
});
import { readonly } from '@angular/forms/signals';
const accountForm = form(this.accountModel, (schemaPath) => {
readonly(schemaPath.username); // 始终只读
});
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()">提交</button>
</form>
`,
})
export class Login {
model = signal({ email: '', password: '' });
form = form(this.model, (schemaPath) => {
required(schemaPath.email);
required(schemaPath.password);
});
onSubmit(event: Event) {
event.preventDefault();
// submit() 标记所有字段为 touched,并在有效时运行回调
submit(this.form, async () => {
await this.authService.login(this.model());
});
}
}
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="产品" />
<input [formField]="item.quantity" type="number" />
<button type="button" (click)="removeItem(i)">移除</button>
</div>
}
<button type="button" (click)="addItem()">添加项目</button>
`,
})
export class Order {
orderModel = signal<Order>({
items: [{ product: '', quantity: 1 }],
});
orderForm = form(this.orderModel, (schemaPath) => {
applyEach(schemaPath.items, (item) => {
required(item.product, { message: '产品为必填项' });
min(item.quantity, 1, { message: '最小数量为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),
}));
}
}
<input [formField]="form.email" />
@if (form.email().touched() && form.email().invalid()) {
<ul class="errors">
@for (error of form.email().errors(); track error) {
<li>{{ error.message }}</li>
}
</ul>
}
@if (form.email().pending()) {
<span>验证中...</span>
}
<input
[formField]="form.email"
[class.is-invalid]="form.email().touched() && form.email().invalid()"
[class.is-valid]="form.email().touched() && form.email().valid()"
/>
async onSubmit() {
if (!this.form().valid()) return;
await this.api.submit(this.model());
// 清除交互状态
this.form().reset();
// 清除值
this.model.set({ email: '', password: '' });
}
关于响应式表单模式(生产环境稳定),请参阅 references/form-patterns.md。
每周安装量
2.5K
代码仓库
GitHub 星标数
494
首次出现
2026年1月24日
安全审计
安装于
github-copilot2.1K
opencode2.0K
gemini-cli2.0K
codex2.0K
amp1.8K
kimi-cli1.8K
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.
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: `
<form (submit)="onSubmit($event)">
<label>
Email
<input type="email" [formField]="loginForm.email" />
</label>
@if (loginForm.email().touched() && loginForm.email().invalid()) {
<p class="error">{{ loginForm.email().errors()[0].message }}</p>
}
<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 Login {
// Form model - a writable signal
loginModel = signal<LoginData>({
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 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<UserProfile>({
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<string>
userForm.preferences.theme // FieldTree<'light' | 'dark'>
// 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();
// 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);
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)
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()
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',
});
});
const orderForm = form(this.orderModel, (schemaPath) => {
required(schemaPath.promoCode, {
message: 'Promo code required for discounts',
when: ({ valueOf }) => valueOf(schemaPath.applyDiscount),
});
});
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;
});
});
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;
});
});
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',
}),
});
});
import { hidden } from '@angular/forms/signals';
const profileForm = form(this.profileModel, (schemaPath) => {
hidden(schemaPath.publicUrl, ({ valueOf }) => !valueOf(schemaPath.isPublic));
});
@if (!profileForm.publicUrl().hidden()) {
<input [formField]="profileForm.publicUrl" />
}
import { disabled } from '@angular/forms/signals';
const orderForm = form(this.orderModel, (schemaPath) => {
disabled(schemaPath.couponCode, ({ valueOf }) => valueOf(schemaPath.total) < 50);
});
import { readonly } from '@angular/forms/signals';
const accountForm = form(this.accountModel, (schemaPath) => {
readonly(schemaPath.username); // Always readonly
});
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 Login {
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());
});
}
}
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 Order {
orderModel = signal<Order>({
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),
}));
}
}
<input [formField]="form.email" />
@if (form.email().touched() && form.email().invalid()) {
<ul class="errors">
@for (error of form.email().errors(); track error) {
<li>{{ error.message }}</li>
}
</ul>
}
@if (form.email().pending()) {
<span>Validating...</span>
}
<input
[formField]="form.email"
[class.is-invalid]="form.email().touched() && form.email().invalid()"
[class.is-valid]="form.email().touched() && form.email().valid()"
/>
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.
Weekly Installs
2.5K
Repository
GitHub Stars
494
First Seen
Jan 24, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
github-copilot2.1K
opencode2.0K
gemini-cli2.0K
codex2.0K
amp1.8K
kimi-cli1.8K
99,500 周安装