Angular自定义表单控件(必填验证)失效问题求助
问题描述
刚接触Angular自定义表单控件,在使用mat-stepper时遇到必填字段验证失效的问题。制作了可复用的地址模板,已在HTML和TS代码中设置必填属性,但点击next按钮仍能跳转到下一步,求解决方案。
相关代码
地址类模型
export declare class Address { unitNumber: string; streetName: string; suburb: string; city: string; province: string; postalCode: string; }
地址组件TS代码
import { Component, ElementRef, HostBinding, Inject, Input, OnDestroy, Optional, Self } from '@angular/core'; import { ControlValueAccessor, NgControl } from '@angular/forms'; import { Address, Helper, SelectOption } from '@trade-up/common'; import { Subject } from 'rxjs'; import { MatFormField, MatFormFieldControl, MAT_FORM_FIELD } from '@angular/material/form-field'; import { coerceBooleanProperty } from '@angular/cdk/coercion'; @Component({ selector: 'app-address-form', templateUrl: './address-form.component.html', styleUrls: ['./address-form.component.scss'], providers: [ {provide: MatFormFieldControl, useExisting: AddressFormComponent} ] }) export class AddressFormComponent implements OnDestroy, ControlValueAccessor, MatFormFieldControl<Address> { @HostBinding('attr.id') externalId: string; @HostBinding('class.floating') get shouldLabelFloat(): boolean { return this.focused || !this.empty; } @HostBinding('class.invalid') get valid(): boolean { return this.touched; } @Input() set id(value: string) { this._ID = value; this.externalId = null; } get id(): string { return this._ID; } provinces: SelectOption[] = Helper.provinces; address: Address; stateChanges = new Subject<void>(); /*tslint:disable-next-line*/ private _ID: string; /*tslint:disable-next-line*/ private _placeholder: string; /*tslint:disable-next-line*/ private _required: boolean; /*tslint:disable-next-line*/ private _disabled: boolean; focused: boolean; errorState: boolean; controlType?: string; autofilled?: boolean; userAriaDescribedBy?: string; touched: boolean; get empty(): boolean { return !this.address.unitNumber && !this.address.streetName && !this.address.suburb && !this.address.province && !this.address.city && !this.address.postalCode; } @Input() get placeholder(): string { return this._placeholder; } set placeholder(plh) { this._placeholder = plh; this.stateChanges.next(); } @Input() get required(): boolean { return this._required; } set required(req) { this._required = coerceBooleanProperty(req); this.stateChanges.next(); } @Input() get disabled(): boolean { return this._disabled; } set disabled(dis) { this._disabled = coerceBooleanProperty(dis); this.stateChanges.next(); } onChange = () => {}; onTouched = () => {}; registerOnChange(fn: any): void { this.onChange = fn; } registerOnTouched(fn: any): void { this.onTouched = fn; } writeValue(add: Address): void { this.address = add; } get value(): Address { return this.address; } set value(add: Address) { this.address = add; this.stateChanges.next(); } setDescribedByIds(ids: string[]): void { this.userAriaDescribedBy = ids.join(' '); } onContainerClick(): void { } ngOnDestroy(): void { this.stateChanges.complete(); } /*tslint:disable-next-line*/ constructor(@Optional() @Self() public ngControl: NgControl, @Optional() @Inject(MAT_FORM_FIELD) public _formField: MatFormField, /*tslint:disable-next-line*/ private _elementRef: ElementRef<HTMLElement>) { if (ngControl !== null) { ngControl.valueAccessor = this; } this.address = new Address(); } }
地址组件HTML代码
<div role="group" class="input-container" [class.invalid]="valid" [attr.aria-labelledby]="_formField?.getLabelId()"> <mat-form-field> <mat-label>Unit Number</mat-label> <input matInput [(ngModel)]="address.unitNumber" [class.invalid]="valid" type="text" maxlength="5" required> </mat-form-field> <br> <mat-form-field> <input matInput [(ngModel)]="address.streetName" [class.invalid]="valid" type="text" placeholder="Street Name" maxLength="45" required> </mat-form-field> <br> <mat-form-field> <input matInput [(ngModel)]="address.suburb" [class.invalid]="valid" type="text" placeholder="Suburb" maxLength="45" required> </mat-form-field> <br> <mat-form-field> <mat-label>Province</mat-label> <mat-select placeholder="Province" [(ngModel)]="address.province" [class.invalid]="valid" required> <mat-option *ngFor="let province of provinces" [value]="province.value"> {{province.viewValue}} </mat-option> </mat-select> </mat-form-field> <br> <mat-form-field> <input matInput type="text" [(ngModel)]="address.city" [class.invalid]="valid" placeholder="City" maxLength="45" required> </mat-form-field> <br> <mat-form-field> <input matInput type="text" [(ngModel)]="address.postalCode" [class.invalid]="valid" placeholder="Postal Code" maxlength="4" pattern="[0-9]*" required> </mat-form-field> </div>
地址收集组件TS代码
this.homeCollectionForm = this.formBuilder.group({ homeAddress: [new Address(), Validators.required], });
地址收集组件HTML代码
<mat-horizontal-stepper linear="true"> <mat-step [stepControl]="homeCollectionForm"> <form [formGroup]="homeCollectionForm"> <mat-card> <mat-card-content> <mat-form-field class="mat-form-field-center"> <app-address-form formControlName="homeAddress" required></app-address-form> </mat-form-field> <button mat-raised-button matStepperNext color="warn"> Next </button> </mat-card-content> </mat-card> </form> </mat-step> </mat-horizontal-stepper>
解决方案
问题核心在于自定义控件未正确实现验证逻辑,父表单无法感知内部字段的有效性。以下是具体修复步骤:
1. 给自定义控件添加验证接口实现
修改地址组件TS,实现Validator接口,添加内部字段的验证逻辑:
// 补充导入 import { ControlValueAccessor, NgControl, Validator, ValidationErrors, AbstractControl } from '@angular/forms'; // 实现Validator接口 export class AddressFormComponent implements OnDestroy, ControlValueAccessor, MatFormFieldControl<Address>, Validator { // ... 原有代码 ... // 实现验证方法,检查所有地址字段是否完整 validate(control: AbstractControl): ValidationErrors | null { const addr = control.value as Address; const errors: ValidationErrors = {}; // 检查必填字段 if (!addr.unitNumber) errors['unitNumberRequired'] = true; if (!addr.streetName) errors['streetNameRequired'] = true; if (!addr.suburb) errors['suburbRequired'] = true; if (!addr.province) errors['provinceRequired'] = true; if (!addr.city) errors['cityRequired'] = true; if (!addr.postalCode) errors['postalCodeRequired'] = true; // 检查邮编格式 else if (!/^[0-9]{4}$/.test(addr.postalCode)) errors['postalCodeInvalid'] = true; return Object.keys(errors).length > 0 ? errors : null; } // 更新验证状态并通知表单 private updateValidation() { if (this.ngControl?.control) { this.ngControl.control.updateValueAndValidity(); this.errorState = !!this.ngControl.errors; this.stateChanges.next(); } } // 修正value setter,触发值变更和验证 set value(add: Address) { this.address = add || new Address(); this.onChange(this.address); this.updateValidation(); this.stateChanges.next(); } // 处理内部输入变化的方法 onInputChange() { this.onChange(this.address); this.updateValidation(); } // 修正valid getter逻辑,正确标记无效状态 @HostBinding('class.invalid') get valid(): boolean { return this.touched && this.errorState; } // 标记控件为已触摸 markAsTouched() { if (!this.touched) { this.touched = true; this.onTouched(); this.updateValidation(); this.stateChanges.next(); } } // 实现Validator接口的可选方法 registerOnValidatorChange?(fn: () => void): void {} // ... 原有代码 ... }
2. 修改组件HTML,绑定输入事件和触摸事件
给每个输入/选择框添加值变更事件和触摸事件,触发验证和状态更新:
<div role="group" class="input-container" [class.invalid]="valid" [attr.aria-labelledby]="_formField?.getLabelId()"> <mat-form-field> <mat-label>Unit Number</mat-label> <input matInput [(ngModel)]="address.unitNumber" [class.invalid]="touched && !address.unitNumber" type="text" maxlength="5" required (ngModelChange)="onInputChange()" (blur)="markAsTouched()"> </mat-form-field> <br> <mat-form-field> <input matInput [(ngModel)]="address.streetName" [class.invalid]="touched && !address.streetName" type="text" placeholder="Street Name" maxLength="45" required (ngModelChange)="onInputChange()" (blur)="markAsTouched()"> </mat-form-field> <br> <mat-form-field> <input matInput [(ngModel)]="address.suburb" [class.invalid]="touched && !address.suburb" type="text" placeholder="Suburb" maxLength="45" required (ngModelChange)="onInputChange()" (blur)="markAsTouched()"> </mat-form-field> <br> <mat-form-field> <mat-label>Province</mat-label> <mat-select placeholder="Province" [(ngModel)]="address.province" [class.invalid]="touched && !address.province" required (selectionChange)="onInputChange()" (blur)="markAsTouched()"> <mat-option *ngFor="let province of provinces" [value]="province.value"> {{province.viewValue}} </mat-option> </mat-select> </mat-form-field> <br> <mat-form-field> <input matInput type="text" [(ngModel)]="address.city" [class.invalid]="touched && !address.city" placeholder="City" maxLength="45" required (ngModelChange)="onInputChange()" (blur)="markAsTouched()"> </mat-form-field> <br> <mat-form-field> <input matInput type="text" [(ngModel)]="address.postalCode" [class.invalid]="touched && (!address.postalCode || !/^[0-9]{4}$/.test(address.postalCode))" placeholder="Postal Code" maxlength="4" pattern="[0-9]*" required (ngModelChange)="onInputChange()" (blur)="markAsTouched()"> </mat-form-field> </div>
3. 初始化触摸状态
在地址组件构造函数中添加touched初始化:
constructor(...) { // ... 原有代码 ... this.touched = false; }
修复说明
- 原代码中父表单的
Validators.required仅检查homeAddress是否为null/undefined,但new Address()创建的是带空字符串属性的对象,因此验证会通过。 - 自定义控件内部的
required是原生HTML验证,不会同步到Angular表单控件状态,必须通过实现Validator接口将内部字段的有效性传递给父表单。 - 通过绑定输入事件和触摸事件,确保控件值变化或失去焦点时,及时更新验证状态并通知父表单,最终让
mat-stepper的stepControl能正确识别表单有效性,阻止next按钮跳转。
内容的提问来源于stack exchange,提问作者Nick
相关产品推荐
相关产品推荐

