Angular Material 自定义表单字段控件(MatFormFieldControl)完全指南:从零实现一个可复用的电话输入组件
【免费下载链接】componentsComponent infrastructure and Material Design components for Angular项目地址: https://gitcode.com/GitHub_Trending/co/components
导读
<mat-form-field>是 Angular Material 中承载输入控件的核心容器,它自带浮动占位符(floating placeholder)、前缀/后缀(prefix/suffix)、提示(hint)、错误信息(error)、必填标记(required marker)与无障碍支持(aria 关联)。默认情况下它只认识matInput、mat-select等内置控件,但通过实现MatFormFieldControl接口并正确注册 provider,你可以让任意自定义组件无缝接入<mat-form-field>,继承其全部外观与行为。本指南以创建一个美国电话号码输入控件为例,逐步讲解MatFormFieldControl的每个方法与属性,并对照本仓库源码(src/material/form-field/form-field-control.ts、src/material/form-field/form-field.ts)与官方示例(src/components-examples/material/form-field/form-field-custom-control)说明其底层原理。读完你将能独立打造属于自己的表单字段控件。
一、为什么需要自定义表单字段控件
<mat-form-field>之所以能统一管理标签、下划线、错误与无障碍信息,是因为它内部依赖一个抽象接口——MatFormFieldControl。从 form-field-control.ts 可以看到,它在仓库中被定义为一个带@Directive()装饰器的抽象类,声明了控件必须实现的值、状态流、焦点/空态/浮动/必填/禁用/错误状态,以及setDescribedByIds与onContainerClick两个抽象方法。
当你需要创建"共享表单字段大量公共行为、但额外增加业务逻辑"的组件时,实现该接口是最正规的途径。本指南要构建的目标是:一个把美国电话号码拆成区号(3 位)、局号(3 位)、用户号(4 位)三段输入的电话输入控件,最终可被如下方式使用:
<mat-form-field> <example-tel-input placeholder="Phone number" required></example-tel-input> </mat-form-field>二、起点:一个普通的分段输入组件
我们先从一个不依赖 Material 的普通组件开始。它用FormGroup管理三个子输入框,并提供一个MyTel值对象:
class MyTel { constructor(public area: string, public exchange: string, public subscriber: string) {} } @Component({ selector: 'example-tel-input', template: ` <div role="group" [formGroup]="parts"> <input class="area" formControlName="area" maxlength="3"> <span>–</span> <input class="exchange" formControlName="exchange" maxlength="3"> <span>–</span> <input class="subscriber" formControlName="subscriber" maxlength="4"> </div> `, styles: [` div { display: flex; } input { border: none; background: none; padding: 0; outline: none; font: inherit; text-align: center; color: currentColor; } `], }) export class MyTelInput { parts: FormGroup; @Input() get value(): MyTel | null { let n = this.parts.value; if (n.area.length == 3 && n.exchange.length == 3 && n.subscriber.length == 4) { return new MyTel(n.area, n.exchange, n.subscriber); } return null; } set value(tel: MyTel | null) { tel = tel || new MyTel('', '', ''); this.parts.setValue({area: tel.area, exchange: tel.exchange, subscriber: tel.subscriber}); } constructor(fb: FormBuilder) { this.parts = fb.group({ 'area': '', 'exchange': '', 'subscriber': '', }); } }注意:本指南示例旨在演示"如何接入表单字段",并非生产级健壮的电话校验组件。完整可运行的增强版(含自动跳格、退格回退、信号式状态管理等)见仓库官方示例 form-field-custom-control。
三、将组件注册为 MatFormFieldControl
<mat-form-field>通过 DI 按 tokenMatFormFieldControl查找内部控件,因此第一步是让组件实现该接口(它是泛型接口,类型参数为本控件的值类型MyTel),并在providers中注册,使表单字段能注入到它:
@Component({ ... providers: [{provide: MatFormFieldControl, useExisting: MyTelInput}], }) export class MyTelInput implements MatFormFieldControl<MyTel> { ... }实现提示:从 form-field-control.ts 的源码可见,接口成员包含
value、stateChanges、id、placeholder、ngControl、focused、empty、shouldLabelFloat、required、disabled、errorState、controlType,以及抽象方法setDescribedByIds、onContainerClick。另外还有三个可选成员:autofilled(是否处于自动填充状态)、userAriaDescribedBy(用户自定义的aria-describedby)、disableAutomaticLabeling(禁止表单字段自动把 label 的for指向控件 id,适用于非原生元素)。下面逐一实现。
四、逐项实现 MatFormFieldControl 的成员
4.1value:控件的读写值
value允许外部设置或读取控件值,其类型应与泛型参数一致(这里是MyTel)。上一节的组件已经有value属性,无需额外改动。
4.2stateChanges:通知表单字段执行变更检测
因为<mat-form-field>使用OnPush变更检测策略,控件内部任何可能影响表单字段外观的状态变化,都必须通过stateChanges流通知父级。值变化时要发射事件,组件销毁时应 complete 该流:
stateChanges = new Subject<void>(); set value(tel: MyTel | null) { ... this.stateChanges.next(); } ngOnDestroy() { this.stateChanges.complete(); }在官方示例 form-field-custom-control-example.ts 中,还通过parts.statusChanges与parts.valueChanges的订阅,把内部FormGroup的任何状态/值变化都转发到stateChanges,并且用effect统一在placeholder、required、disabled、focused等信号变化时触发stateChanges.next()——这是现代信号写法下"一处集中发射"的典型做法。
4.3id:表单字段关联 label 与 hint 的目标元素
该 id 会被<mat-form-field>用于把标签(label)和提示(hint)关联到控件。本示例直接绑定到宿主元素并生成唯一 id:
static nextId = 0; @HostBinding() id = `example-tel-input-${MyTelInput.nextId++}`;4.4placeholder:占位符
与matInput、mat-select一样,用@Input()让用户指定占位符。占位符可能变化,因此 setter 中需要发射stateChanges触发父级变更检测:
@Input() get placeholder() { return this._placeholder; } set placeholder(plh) { this._placeholder = plh; this.stateChanges.next(); } private _placeholder: string;4.5ngControl:关联的 @angular/forms 控件
该属性用于暴露与本组件绑定的NgControl。若组件未实现ControlValueAccessor,可先置为null:
ngControl: NgControl = null;接入formControl/ngModel(推荐):若要支持formControl与ngModel绑定,通常需要实现ControlValueAccessor,并在构造函数中通过 DI 拿到NgControl并公开:
constructor( ..., @Optional() @Self() public ngControl: NgControl, ..., ) { }循环依赖陷阱:若组件同时通过NG_VALUE_ACCESSOR(在providers或模块声明中)提供了 value accessor,会抛出cannot instantiate cyclic dependency错误。解决办法是移除该 provider,改为直接赋值valueAccessor:
@Component({ ..., providers: [ ..., // Remove this. // { // provide: NG_VALUE_ACCESSOR, // useExisting: forwardRef(() => MatFormFieldControl), // multi: true, // }, ], }) export class MyTelInput implements MatFormFieldControl<MyTel>, ControlValueAccessor { constructor( ..., @Optional() @Self() public ngControl: NgControl, ..., ) { // Replace the provider from above with this. if (this.ngControl != null) { // Setting the value accessor directly (instead of using // the providers) to avoid running into a circular import. this.ngControl.valueAccessor = this; } } }官方示例正是采用这种写法(见 form-field-custom-control-example.ts),并实现了writeValue、registerOnChange、registerOnTouched、setDisabledState四个 CVA 方法,让控件既能通过模板绑定formControlName="tel",又能正确同步禁用状态。
4.6focused:焦点状态
表单字段在控件聚焦时显示实色下划线,因此需要上报焦点状态。本示例用focusin/focusout事件判断"任一分段输入框是否聚焦",同时更新内部 touched 状态以驱动错误显示:
focused = false; onFocusIn(event: FocusEvent) { if (!this.focused) { this.focused = true; this.stateChanges.next(); } } onFocusOut(event: FocusEvent) { if (!this._elementRef.nativeElement.contains(event.relatedTarget as Element)) { this.touched = true; this.focused = false; this.onTouched(); this.stateChanges.next(); } }4.7empty:空态判断
用于决定标签是否上浮。本控件在所有分段均为空时视为空:
get empty() { let n = this.parts.value; return !n.area && !n.exchange && !n.subscriber; }4.8shouldLabelFloat:标签是否上浮
与matInput逻辑一致:聚焦或非空时标签上浮。由于标签未上浮时会与控件重叠,还需隐藏分段之间的–分隔符:
@HostBinding('class.floating') get shouldLabelFloat() { return this.focused || !this.empty; }span { opacity: 0; transition: opacity 200ms; } :host.floating span { opacity: 1; }官方示例使用host: {'[class.example-floating]': 'shouldLabelFloat'}绑定等价实现(见 form-field-custom-control-example.ts),其 CSS 见 example-tel-input-example.css。
4.9required:必填标记
表单字段据此在占位符上追加必填指示符。状态变化时同样需要发射stateChanges。仓库推荐用@angular/cdk/coercion的coerceBooleanProperty把字符串/布尔输入归一化(BooleanInput类型):
@Input() get required() { return this._required; } set required(req: BooleanInput) { this._required = coerceBooleanProperty(req); this.stateChanges.next(); } private _required = false;从源码看,
coerceBooleanProperty来自@angular/cdk/coercion,而BooleanInput是其导出的工具类型(参见 form-field.ts 的导入用法)。官方示例用input<boolean, unknown>(false, {alias: 'required', transform: booleanAttribute})实现同样的布尔归一化。
4.10disabled:禁用状态
除向表单字段上报禁用态外,还必须同步禁用内部各分段输入框(此处通过禁用/启用整个FormGroup实现):
@Input() get disabled(): boolean { return this._disabled; } set disabled(value: BooleanInput) { this._disabled = coerceBooleanProperty(value); this._disabled ? this.parts.disable() : this.parts.enable(); this.stateChanges.next(); } private _disabled = false;注意:若实现了ControlValueAccessor,还应让 CVA 的setDisabledState与@Input() disabled合并(官方示例用computed(() => this._disabledByInput() || this._disabledByCva())处理,见 form-field-custom-control-example.ts),避免"模板禁用"与"表单禁用"互相覆盖。
4.11errorState:错误状态
用于告知表单字段关联的NgControl是否处于错误状态。简单场景可直接根据内部表单与 touched 计算:
get errorState(): boolean { return this.parts.invalid && this.touched; }更完整的做法:某些错误触发器无法订阅(例如父表单的提交事件),因此应在每个变更检测周期重估errorState:
/** Whether the component is in an error state. */ errorState: boolean = false; constructor( ..., @Optional() private _parentForm: NgForm, @Optional() private _parentFormGroup: FormGroupDirective ) { ... } ngDoCheck() { if (this.ngControl) { this.updateErrorState(); } } private updateErrorState() { const parentSubmitted = this._parentFormGroup?.submitted || this._parentForm?.submitted; const touchedOrParentSubmitted = this.touched || parentSubmitted; const newState = (this.ngControl?.invalid || this.parts.invalid) && touchedOrParentSubmitted; if (this.errorState !== newState) { this.errorState = newState; this.stateChanges.next(); // Notify listeners of state changes. } }性能注意:updateErrorState()必须保持最小逻辑,避免在ngDoCheck中造成性能问题。
4.12controlType:控件类型标识
提供一个唯一字符串作为控件类型,表单字段会据此在自身根元素上追加mat-form-field-type-{{controlType}}类,方便按控件类型定制样式:
controlType = 'example-tel-input';以本示例为例,将得到类mat-form-field-type-example-tel-input。从 form-field.ts 的源码可见,表单字段在切换控件时会移除上一个控件的类型类、添加新控件的类型类:
this._elementRef.nativeElement.classList.remove(classPrefix + previousControl.controlType); ... if (control.controlType) { this._elementRef.nativeElement.classList.add(classPrefix + control.controlType); }4.13setDescribedByIds(ids: string[]):无障碍 aria-describedby 关联
表单字段在提示(hint)或错误(error)条件性显示时,会调用此方法传入应关联的元素 id,控件需据此更新自身的aria-describedby属性。默认实现不会保留用户手工写在控件元素上的aria-describedby;为避免覆盖用户指定的 id,应创建名为userAriaDescribedBy的输入:
@Input('aria-describedby') userAriaDescribedBy: string;表单字段会在每次setDescribedByIds被调用时,把用户指定的 id 与 hint/error 的 id 合并。控件内实现:
setDescribedByIds(ids: string[]) { const controlElement = this._elementRef.nativeElement .querySelector('.example-tel-input-container')!; controlElement.setAttribute('aria-describedby', ids.join(' ')); }仓库侧的合并逻辑在 form-field.ts 中:_syncDescribedByIds读取control.userAriaDescribedBy拆分为 id 列表并追加,还会保留此前由AriaDescriber等直接赋值的既有 id(通过describedByIds缓存过滤避免重复,参见#30011修复),最终调用control.setDescribedByIds(toAssign)。这意味着实现类应把每次传入的 ids 缓存到describedByIds(可选成员)中,以保证多次调用时增量正确。
4.14onContainerClick(event: MouseEvent):容器点击处理
当用户点击整个表单字段区域时触发,可自定义点击行为。本示例在用户没有直接点击输入框时,把焦点移到第一个输入框:
onContainerClick(event: MouseEvent) { if ((event.target as Element).tagName.toLowerCase() != 'input') { this._elementRef.nativeElement.querySelector('input').focus(); } }官方示例的增强版更智能:按"已填分段"依次回退聚焦到下一个待填输入框(见 form-field-custom-control-example.ts),并借助FocusMonitor.focusVia(..., 'program')以编程方式聚焦,便于无障碍追踪焦点来源。
五、可访问性改进
自定义控件由多个输入框组成,应将它们放进带role="group"的容器,让屏幕阅读器用户明确"这些输入框属于同一组":
<div role="group" [formGroup]="parts" ...>但仅有分组还不够——屏幕阅读器用户无法得知该组的含义,需要为分组提供标签。推荐把组与父级<mat-form-field>显示出的<mat-label>关联起来,确保显式指定的标签真正用于标注控件。具体做法是通过可选注入拿到父表单字段实例,并绑定getLabelId():
export class MyTelInput implements MatFormFieldControl<MyTel> { ... constructor(..., @Optional() public parentFormField: MatFormField) {@Component({ selector: 'example-tel-input', template: ` <div role="group" [formGroup]="parts" [attr.aria-describedby]="describedBy" [attr.aria-labelledby]="parentFormField?.getLabelId()">从源码看,getLabelId是 form-field.ts 中的一个computed:当存在浮动标签时返回内部生成的_labelId,否则返回null。因此只要控件被包在<mat-form-field>内且提供了<mat-label>,aria-labelledby就会自动指向该标签。官方示例使用inject(MAT_FORM_FIELD, {optional: true})获取父表单字段(见 form-field-custom-control-example.ts),MAT_FORM_FIELD正是仓库为"避免强引用组件类与元数据"而提供的注入令牌(定义于 form-field.ts)。此外,示例还给每个分段输入框补充了aria-label(如 "Area code"),进一步提升逐段朗读的可理解性。
六、实际使用:放入<mat-form-field>并享受全部特性
接口实现完成后,只需把组件放进<mat-form-field>即可工作:
<mat-form-field> <example-tel-input></example-tel-input> </mat-form-field>由于实现了MatFormFieldControl,组件自动获得浮动占位符、前缀、后缀、提示、错误等全部特性(前提是给表单字段一个NgControl并正确上报错误状态):
<mat-form-field> <example-tel-input placeholder="Phone number" required></example-tel-input> <mat-icon matPrefix>phone</mat-icon> <mat-hint>Include area code</mat-hint> </mat-form-field>若要完整验证errorState等行为,应配合响应式表单使用。仓库官方示例的完整用法(见 form-field-custom-control-example.html):
<div [formGroup]="form"> <mat-form-field> <mat-label>Phone number</mat-label> <example-tel-input formControlName="tel" required></example-tel-input> <mat-icon matSuffix>phone</mat-icon> <mat-hint>Include area code</mat-hint> </mat-form-field> <p>Entered value: {{form.valueChanges | async | json}}</p> </div>其 TypeScript 侧只需一个表单:
export class FormFieldCustomControlExample { readonly form = new FormGroup({ tel: new FormControl(null), }); }七、小结与扩展建议
创建自定义表单字段控件的完整套路可归纳为四步:
- 实现接口:
class MyCtrl implements MatFormFieldControl<MyValue>,按上文逐一实现value、stateChanges、id、placeholder、ngControl、focused、empty、shouldLabelFloat、required、disabled、errorState、controlType、setDescribedByIds、onContainerClick; - 注册 provider:
providers: [{provide: MatFormFieldControl, useExisting: MyCtrl}]; - (可选但推荐)实现
ControlValueAccessor:支持formControl/ngModel,并在构造函数中直接赋值ngControl.valueAccessor以规避循环依赖; - 保证无障碍:用
role="group"聚合子控件,通过parentFormField.getLabelId()关联<mat-label>,并用setDescribedByIds同步 hint/error 的aria-describedby。
关于接口本身的完整成员定义(含可选的autofilled、userAriaDescribedBy、disableAutomaticLabeling),可查阅 form-field-control.ts;关于表单字段如何消费这些成员(类型类切换、描述 id 合并、label id 生成),可深入 form-field.ts;配套的可运行示例代码与测试脚手架位于 src/components-examples/material/form-field/form-field-custom-control,以及本组件包的 form-field.md 与 README.md。把本指南中的MyTelInput换成你的业务组件(如邮编、日期区间、验证码等),即可在完全复用<mat-form-field>视觉体系的前提下构建任意复杂度的输入控件。
【免费下载链接】componentsComponent infrastructure and Material Design components for Angular项目地址: https://gitcode.com/GitHub_Trending/co/components
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考