Angular Material 自定义表单字段控件(MatFormFieldControl)完全指南:从零实现一个可复用的电话输入组件 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 placeholderPhone 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 rolegroup [formGroup]parts input classarea formControlNamearea maxlength3 spanndash;/span input classexchange formControlNameexchange maxlength3 spanndash;/span input classsubscriber formControlNamesubscriber maxlength4 /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。三、将组件注册为 MatFormFieldControlmat-form-field通过 DI 按 tokenMatFormFieldControl查找内部控件因此第一步是让组件实现该接口它是泛型接口类型参数为本控件的值类型MyTel并在providers中注册使表单字段能注入到它Component({ ... providers: [{provide: MatFormFieldControl, useExisting: MyTelInput}], }) export class MyTelInput implements MatFormFieldControlMyTel { ... }实现提示从 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 Subjectvoid(); 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关联到控件。本示例直接绑定到宿主元素并生成唯一 idstatic 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可先置为nullngControl: 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改为直接赋值valueAccessorComponent({ ..., providers: [ ..., // Remove this. // { // provide: NG_VALUE_ACCESSOR, // useExisting: forwardRef(() MatFormFieldControl), // multi: true, // }, ], }) export class MyTelInput implements MatFormFieldControlMyTel, 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 方法让控件既能通过模板绑定formControlNametel又能正确同步禁用状态。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 的导入用法。官方示例用inputboolean, 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)以编程方式聚焦便于无障碍追踪焦点来源。五、可访问性改进自定义控件由多个输入框组成应将它们放进带rolegroup的容器让屏幕阅读器用户明确这些输入框属于同一组div rolegroup [formGroup]parts ...但仅有分组还不够——屏幕阅读器用户无法得知该组的含义需要为分组提供标签。推荐把组与父级mat-form-field显示出的mat-label关联起来确保显式指定的标签真正用于标注控件。具体做法是通过可选注入拿到父表单字段实例并绑定getLabelId()export class MyTelInput implements MatFormFieldControlMyTel { ... constructor(..., Optional() public parentFormField: MatFormField) {Component({ selector: example-tel-input, template: div rolegroup [formGroup]parts [attr.aria-describedby]describedBy [attr.aria-labelledby]parentFormField?.getLabelId()从源码看getLabelId是 form-field.ts 中的一个computed当存在浮动标签时返回内部生成的_labelId否则返回null。因此只要控件被包在mat-form-field内且提供了mat-labelaria-labelledby就会自动指向该标签。官方示例使用inject(MAT_FORM_FIELD, {optional: true})获取父表单字段见 form-field-custom-control-example.tsMAT_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 placeholderPhone number required/example-tel-input mat-icon matPrefixphone/mat-icon mat-hintInclude area code/mat-hint /mat-form-field若要完整验证errorState等行为应配合响应式表单使用。仓库官方示例的完整用法见 form-field-custom-control-example.htmldiv [formGroup]form mat-form-field mat-labelPhone number/mat-label example-tel-input formControlNametel required/example-tel-input mat-icon matSuffixphone/mat-icon mat-hintInclude area code/mat-hint /mat-form-field pEntered value: {{form.valueChanges | async | json}}/p /div其 TypeScript 侧只需一个表单export class FormFieldCustomControlExample { readonly form new FormGroup({ tel: new FormControl(null), }); }七、小结与扩展建议创建自定义表单字段控件的完整套路可归纳为四步实现接口class MyCtrl implements MatFormFieldControlMyValue按上文逐一实现value、stateChanges、id、placeholder、ngControl、focused、empty、shouldLabelFloat、required、disabled、errorState、controlType、setDescribedByIds、onContainerClick注册 providerproviders: [{provide: MatFormFieldControl, useExisting: MyCtrl}]可选但推荐实现ControlValueAccessor支持formControl/ngModel并在构造函数中直接赋值ngControl.valueAccessor以规避循环依赖保证无障碍用rolegroup聚合子控件通过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),仅供参考