NOTE: 本指南假設你已熟悉 Signal Forms 基礎。
瀏覽器內建的表單控制項(如 input、select、textarea)可以處理常見情況,但應用程式常需要專門的輸入方式。附月曆 UI 的日期選擇器、附格式工具列的富文字編輯器,或附自動完成的標籤選擇器,都需要自訂實作。
Signal Forms 能與任何實作了特定介面的元件搭配運作。控制項介面定義了讓你的元件能與表單系統溝通的屬性與訊號。元件只要實作其中一個介面,[formField] 指令就會自動把你的控制項連接到表單狀態、驗證與資料綁定。
HELPFUL: 自訂 Signal Form 控制項可以與 Signal、Reactive 與 Template-Driven Forms 搭配使用,不需要任何額外的相容程式碼。
建立基本的自訂控制項
我們先從最小可行的實作開始,再依需要加入功能。
最小輸入控制項
基本的自訂輸入只需要實作 FormValueControl 介面,並定義必要的 value 模型訊號。
import {Component, model} from '@angular/core';
import {FormValueControl} from '@angular/forms/signals';
@Component({
selector: 'app-basic-input',
template: `
<div class="basic-input">
<input
type="text"
[value]="value()"
(input)="value.set($event.target.value)"
placeholder="Enter text..."
/>
</div>
`,
})
export class BasicInput implements FormValueControl<string> {
/** The current input value */
value = model('');
}
最小核取方塊控制項
核取方塊類型的控制項需要兩件事:
- 實作
FormCheckboxControl介面,讓FormField指令把它辨識為表單控制項 - 提供
checked模型訊號
import {Component, model, ChangeDetectionStrategy} from '@angular/core';
import {FormCheckboxControl} from '@angular/forms/signals';
@Component({
selector: 'app-basic-toggle',
template: `
<button type="button" [class.active]="checked()" (click)="toggle()">
<span class="toggle-slider"></span>
</button>
`,
})
export class BasicToggle implements FormCheckboxControl {
/** Whether the toggle is checked */
checked = model<boolean>(false);
toggle() {
this.checked.update((val) => !val);
}
}
使用你的自訂控制項
控制項建立好之後,在任何會用內建輸入的位置都能用它,只要在上面加上 FormField 指令即可:
import {Component, signal, ChangeDetectionStrategy} from '@angular/core';
import {form, FormField, required} from '@angular/forms/signals';
import {BasicInput} from './basic-input';
import {BasicToggle} from './basic-toggle';
@Component({
imports: [FormField, BasicInput, BasicToggle],
template: `
<form novalidate>
<label>
Email
<app-basic-input [formField]="registrationForm.email" />
</label>
<label>
Accept terms
<app-basic-toggle [formField]="registrationForm.acceptTerms" />
</label>
<button type="submit" [disabled]="registrationForm().invalid()">Register</button>
</form>
`,
})
export class Registration {
registrationModel = signal({
email: '',
acceptTerms: false,
});
registrationForm = form(this.registrationModel, (schemaPath) => {
required(schemaPath.email, {message: 'Email is required'});
required(schemaPath.acceptTerms, {message: 'You must accept the terms'});
});
}
NOTE: 結構定義回呼參數(這些範例中的 schemaPath)是一個 SchemaPathTree 物件,提供表單中所有欄位的路徑。這個參數你可以取任何名字。
[formField] 指令對自訂控制項與內建輸入的運作方式完全相同。Signal Forms 一視同仁:驗證會執行、狀態會更新、資料綁定自動運作。
了解控制項介面
看過自訂控制項實際運作後,接著來探討它們如何與 Signal Forms 整合。
控制項介面
你建立的 BasicInput 與 BasicToggle 元件實作了特定的控制項介面,告訴 Signal Forms 如何與它們互動。
FormValueControl
FormValueControl 是大多數輸入類型的介面:文字輸入、數字輸入、日期選擇器、select 下拉選單,以及任何編輯單一值的控制項。元件實作這個介面時:
- 必要屬性:你的元件必須提供
value模型訊號 - FormField 指令的行為:把表單欄位的值綁定到你的控制項的
value訊號
IMPORTANT: 實作 FormValueControl 的控制項不可以有 checked 屬性
FormCheckboxControl
FormCheckboxControl 是核取方塊類型控制項的介面:切換開關、開關,以及任何表示布林值開/關狀態的控制項。元件實作這個介面時:
- 必要屬性:你的元件必須提供
checked模型訊號 - FormField 指令的行為:把表單欄位的值綁定到你的控制項的
checked訊號
IMPORTANT: 實作 FormCheckboxControl 的控制項不可以有 value 屬性
可選的狀態屬性
FormValueControl 與 FormCheckboxControl 都繼承自 FormUiControl,這是一個基礎介面,提供與表單狀態整合的可選屬性。
所有屬性都是可選的,只實作你的控制項需要的即可。
互動狀態
追蹤使用者何時與你的控制項互動:
| 屬性 | 用途 |
|---|---|
touched |
使用者是否已與該欄位互動 |
dirty |
使用者是否修改過值,即使後來又改回來也算 |
驗證狀態
向使用者顯示驗證回饋:
| 屬性 | 用途 |
|---|---|
errors |
目前驗證錯誤的陣列 |
invalid |
欄位是否有驗證錯誤 |
pending |
非同步驗證是否正在進行中 |
可用狀態
控制使用者能否與你的欄位互動:
| 屬性 | 用途 |
|---|---|
disabled |
欄位是否已停用 |
disabledReasons |
欄位被停用的原因 |
readonly |
欄位是否為唯讀(可見但不可編輯) |
hidden |
欄位是否已隱藏、不顯示 |
NOTE: disabledReasons 是 DisabledReason 物件的陣列。每個物件都有一個 fieldTree 屬性(參照到欄位樹)與一個可選的 message 屬性。透過 reason.message 存取訊息。
驗證限制
從表單接收驗證限制的值:
| 屬性 | 用途 |
|---|---|
required |
欄位是否必填 |
min |
數值下限(沒有約束時為 undefined) |
max |
數值上限(沒有約束時為 undefined) |
minLength |
字串長度下限(沒有約束時為 undefined) |
maxLength |
字串長度上限(沒有約束時為 undefined) |
pattern |
要比對的正規表示式模式陣列 |
欄位中繼資料
| 屬性 | 用途 |
|---|---|
name |
欄位的 name 屬性(在表單與應用程式間全域唯一) |
下方的「加入狀態訊號」一節會展示如何在你的控制項中實作這些屬性。
FormField 指令的運作方式
[formField] 指令會偵測你的控制項實作了哪個介面,並自動綁定相應的訊號:
import {Component, signal, ChangeDetectionStrategy} from '@angular/core';
import {form, FormField, required} from '@angular/forms/signals';
import {CustomInput} from './custom-input';
import {CustomToggle} from './custom-toggle';
@Component({
selector: 'app-my-form',
imports: [FormField, CustomInput, CustomToggle],
template: `
<form novalidate>
<app-custom-input [formField]="userForm.username" />
<app-custom-toggle [formField]="userForm.subscribe" />
</form>
`,
})
export class MyForm {
formModel = signal({
username: '',
subscribe: false,
});
userForm = form(this.formModel, (schemaPath) => {
required(schemaPath.username, {message: 'Username is required'});
});
}
TIP: 想完整了解如何建立與管理表單模型,請見表單模型指南。
當你綁定 [formField]="userForm.username" 時,FormField 指令會:
- 偵測到你的控制項實作了
FormValueControl - 在內部存取
userForm.username().value(),並把它綁定到你的控制項的value模型訊號 - 把表單狀態訊號(
disabled()、errors()等)綁定到你的控制項的可選輸入訊號 - 透過訊號的反應性自動進行更新
加入狀態訊號
上面的最小控制項可以運作,但不會回應表單狀態。你可以加入可選的輸入訊號,讓你的控制項能對停用狀態做出反應、顯示驗證錯誤,並追蹤使用者互動。
以下是一個實作了常見狀態屬性的完整範例:
import {Component, model, input, output, ChangeDetectionStrategy} from '@angular/core';
import {
FormValueControl,
WithOptionalFieldTree,
ValidationError,
DisabledReason,
} from '@angular/forms/signals';
@Component({
selector: 'app-stateful-input',
template: `
@if (!hidden()) {
<div class="input-container">
<input
type="text"
[value]="value()"
(input)="value.set($event.target.value)"
[disabled]="disabled()"
[readonly]="readonly()"
[class.invalid]="invalid()"
[attr.aria-invalid]="invalid()"
(blur)="touch.emit()"
/>
@if (invalid()) {
<div class="error-messages" role="alert">
@for (error of errors(); track error) {
<span class="error">{{ error.message }}</span>
}
</div>
}
@if (disabled() && disabledReasons().length > 0) {
<div class="disabled-reasons">
@for (reason of disabledReasons(); track reason) {
<span>{{ reason.message }}</span>
}
</div>
}
</div>
}
`,
})
export class StatefulInput implements FormValueControl<string> {
// Required
value = model<string>('');
// Writable interaction state - control updates these
touched = input<boolean>(false);
touch = output<void>();
// Read-only state - form system manages these
disabled = input<boolean>(false);
disabledReasons = input<readonly WithOptionalFieldTree<DisabledReason>[]>([]);
readonly = input<boolean>(false);
hidden = input<boolean>(false);
invalid = input<boolean>(false);
errors = input<readonly WithOptionalFieldTree<ValidationError>[]>([]);
}
如此一來,你就能搭配驗證與狀態管理來使用這個控制項:
import {Component, signal, ChangeDetectionStrategy} from '@angular/core';
import {form, FormField, required, email} from '@angular/forms/signals';
import {StatefulInput} from './stateful-input';
@Component({
imports: [FormField, StatefulInput],
template: `
<form novalidate>
<label>
Email
<app-stateful-input [formField]="loginForm.email" />
</label>
</form>
`,
})
export class Login {
loginModel = signal({email: ''});
loginForm = form(this.loginModel, (schemaPath) => {
required(schemaPath.email, {message: 'Email is required'});
email(schemaPath.email, {message: 'Enter a valid email address'});
});
}
當使用者輸入無效的 email 時,FormField 指令會自動更新 invalid() 與 errors()。你的控制項可以據此顯示驗證回饋。
搭配 debounce('blur')
debounce('blur') 規則會把從 UI 到表單模型的更新延後到欄位失去焦點為止,而不是每次按鍵就套用。內建控制項會自動向表單回報 blur。 自訂控制項只有在回應原生 blur 事件 時發射 touch 輸出,才算參與其中:
import {Component, model, output} from '@angular/core';
import {FormValueControl} from '@angular/forms/signals';
@Component({
selector: 'app-custom-input',
template: `
<input
type="text"
[value]="value()"
(input)="value.set($event.target.value)"
(blur)="touch.emit()"
/>
`,
})
export class CustomInput implements FormValueControl<string> {
value = model('');
touch = output<void>();
}
有了 touch 輸出,debounce('blur') 在你的控制項上的行為就與內建輸入相同:
import {Component, signal} from '@angular/core';
import {debounce, form, FormField} from '@angular/forms/signals';
import {CustomInput} from './custom-input';
@Component({
selector: 'app-root',
imports: [CustomInput, FormField],
template: `<app-custom-input [formField]="userForm.name" />`,
})
export class App {
userModel = signal({name: ''});
userForm = form(this.userModel, (schemaPath) => {
debounce(schemaPath.name, 'blur');
});
}
IMPORTANT: 要在 blur(焦點離開控制項)時發射 touch,不要在 focus 時。控制項若不發射 touch,欄位永遠不會被標示為 touched,debounce('blur') 也不會把控制項發出的值寫入表單模型。
值轉換
控制項顯示的值有時與表單模型儲存的值不同:日期選擇器可能顯示「January 15, 2024」,儲存的卻是「2024-01-15」;貨幣輸入可能顯示「$1,234.56」,儲存的卻是 1234.56。
使用 transformedValue()(來自 @angular/forms/signals),可以讓 UI 顯示的原始值與模型值保持同步。它接收控制項的 value 模型訊號,加上一個 parse 函式與一個 format 函式,回傳一個存放原始值的可寫入訊號:
format把模型值轉換成範本要呈現的原始值。parse把使用者輸入的內容轉換回模型值,也可以改回報剖析錯誤。
import {Component, model} from '@angular/core';
import {FormValueControl, transformedValue} from '@angular/forms/signals';
@Component({
selector: 'number-input',
template: `
<input type="text" [value]="rawValue()" (input)="rawValue.set($event.target.value)" />
`,
})
export class NumberInput implements FormValueControl<number | null> {
readonly value = model.required<number | null>();
protected readonly rawValue = transformedValue(this.value, {
parse: (val: string): {value: number | null} => ({value: val ? Number(val) : null}),
format: (val: number | null): string => val?.toString() ?? '',
});
}
寫入回傳的訊號(rawValue.set(...))會執行 parse,並把結果寫入 value。每當模型從其他地方改變 — 可能是 reset()、結構定義規則,或應用程式的其他部分 — format 會再次執行,原始值隨之更新以保持一致。
回報剖析錯誤
有時原始值沒有有效的模型表示法:打到一半的日期,或數字欄位中的英文字母。上面的 NumberInput 就有這個問題:Number('abc') 是 NaN,而 parse 會毫不遲疑地把它寫入模型。
這時改回傳 {error}:
export class NumberInput implements FormValueControl<number | null> {
readonly value = model.required<number | null>();
protected readonly rawValue = transformedValue(this.value, {
parse: (val) => {
const parsed = val ? Number(val) : null;
return Number.isNaN(parsed)
? {error: {kind: 'parse', message: `${val} is not a number`}}
: {value: parsed};
},
format: (val) => val?.toString() ?? '',
});
}
當你想更新模型「同時」標示問題時,就同時回傳 value 與 error。
控制項以 [formField] 綁定時,剖析錯誤會自動回報給欄位,因此會與驗證錯誤一起出現在欄位的 errors() 訊號中:
@Component({
imports: [NumberInput, FormField],
template: `
<number-input [formField]="orderForm.amount" />
@for (error of orderForm.amount().errors(); track $index) {
<!-- {kind: 'parse', message: '...'} is reported here too -->
<p class="error">{{ error.message }}</p>
}
`,
})
export class Order {
orderModel = signal<{amount: number | null}>({amount: null});
orderForm = form(this.orderModel);
}
有剖析錯誤的欄位即為無效,會像未通過驗證規則一樣阻擋送出。
HELPFUL: Signal Forms 對原生輸入也使用同一套機制。當瀏覽器無法剖析某個值時(例如在 <input type="date"> 中打到一半的日期),它會以欄位上的 parse 錯誤形式呈現。詳見原生 HTML 驗證。
重設
在欄位上呼叫 reset() 會清除所有待處理的剖析錯誤,並從模型重新格式化原始值,讓停留在無法剖析狀態的控制項回到乾淨的顯示值:
orderForm.amount().reset();
驗證整合
控制項會顯示驗證狀態,但不執行驗證。驗證發生在表單結構定義中:你的控制項從 FormField 指令接收 invalid() 與 errors() 訊號並顯示出來(如上方的 StatefulInput 範例所示)。
FormField 指令也會傳入驗證限制的值,如 required、min、max、minLength、maxLength 與 pattern。你的控制項可以利用這些值強化 UI:
export class NumberInput implements FormValueControl<number> {
value = model<number>(0);
// Constraint values from schema validation rules
required = input<boolean>(false);
min = input<number | undefined>(undefined);
max = input<number | undefined>(undefined);
}
當你把 min() 與 max() 驗證規則加入結構定義時,FormField 指令會把這些值傳給你的控制項。用它們來套用 HTML5 屬性,或在範本中顯示限制提示。
IMPORTANT: 不要在控制項中實作驗證邏輯。把驗證規則定義在表單結構定義中,讓控制項顯示結果就好:
// Avoid: Validation in control
export class BadControl implements FormValueControl<string> {
value = model<string>('');
isValid() {
return this.value().length >= 8;
} // Don't do this!
}
// Good: Validation in schema, control displays results
accountForm = form(this.accountModel, (schemaPath) => {
minLength(schemaPath.password, 8, {message: 'Password must be at least 8 characters'});
});
讓控制項可重複使用
自訂控制項對驗證常帶有隱含的預期。email 輸入在每個使用它的表單中都需要 required 與 email 規則。不要仰賴每位使用者重新宣告這些規則,你可以把一個配套的結構定義與控制項打包在一起,從同一個模組同時匯出兩者:
email-input.ts
import {schema, required, email} from '@angular/forms/signals';
export const emailFieldSchema = schema<string>((path) => {
required(path, {message: 'Email is required'});
email(path, {message: 'Enter a valid email address'});
});
使用者匯入這個配套結構定義,再用 apply() 把它組合進自己的表單:
registration.ts
import {form, apply} from '@angular/forms/signals';
import {emailFieldSchema} from './email-input';
registrationForm = form(this.registrationModel, (path) => {
apply(path.email, emailFieldSchema);
});
apply() 會把配套結構定義的規則合併到父表單的指定路徑。使用者仍然可以在同一欄位加入更多規則,因為 apply() 是與其他規則組合,而不是取代它們。schema()、apply() 以及用 applyWhen() 做條件式組合的完整說明,請見結構定義指南。
設計考量
使用者的模型必須為每個欄位初始化一個已定義的值。在 Signal Forms 中,undefined 表示欄位不存在,而不是空值。對可重複使用的 email 控制項來說,這表示使用者應該用 '' 作為初始值,不要讓屬性保持未定義。選擇初始值的細節請見表單模型指南。
此外,控制項不應為狀態管理註冊自己的 effect。表單系統透過內部 effect 管理欄位狀態,也就是說,你的控制項是透過輸入訊號接收狀態更新。若控制項需要轉換值,請用「值轉換」一節所示的 transformedValue(),而不是 effect()。
下一步
本指南說明了如何建立與 Signal Forms 整合的自訂控制項。相關指南探討了 Signal Forms 的其他面向: