You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Angular Material 17自定义DateTimePicker父表单无效状态传递问题

问题:Angular Material 17 自定义DateTimePicker无法继承父表单无效状态

我开发了一个自定义DateTimePicker组件,包含文本输入框和Material日期选择器,向外返回单个Date值。升级到Angular Material 17后,原本通过绑定mat-form-field-invalid类传递父表单无效状态的方法失效,无法让子组件的表单字段同步父表单的无效状态。

模板代码

<div fxLayoutAlign="center center" fxLayoutGap="10px" [formGroup]="form">
  <mat-form-field  appearance="outline" class="no-padding no-underline" [class.mat-form-field-invalid]="control?.invalid&&!control.hasError('badTime')"  [class.ng-invalid]="control?.invalid&&!control.hasError('badTime')">
    <mat-label>{{label}}</mat-label>
    <input matInput [matDatepicker]="picker" [max]="max" [min]="min" (dateChange)="changeDate($event)" readonly formControlName="date" (focus)="picker.open()" [errorStateMatcher]="matcher">
    <mat-datepicker #picker></mat-datepicker>
  </mat-form-field>
  <app-timepicker [test]="control" [showSeconds]="showSec" formControlName="time" [label]="label" (timeChanged)="changeTime($event)" (overMidnight)="onOverMidnight($event)"></app-timepicker>
</div>

组件代码

import { DatePipe } from '@angular/common';
import { Component, EventEmitter, Input, OnInit, Output, forwardRef } from '@angular/core';
import { ControlValueAccessor, UntypedFormBuilder, UntypedFormGroup, NgControl, NG_VALUE_ACCESSOR, FormControl, FormGroupDirective, NgForm } from '@angular/forms';
import { ErrorStateMatcher } from '@angular/material/core';

@Component({
  selector: 'app-datetimepicker',
  templateUrl: './datetimepicker.component.html',
  styleUrls: ['./datetimepicker.component.scss'],
  providers: [
  ]

})
export class DatetimepickerComponent implements ControlValueAccessor{
  matcher = new MyErrorStateMatcher();
  value:Date=null;
  form:UntypedFormGroup=null;
  onChange = (value) => {};
  onTouched = () => {};
  touched = false;
  disabled = false;
  init=false;
  _disableTime=false;
  _disableDate=false;
  @Input('disableDate') set disableDate(value:boolean){
    this._disableDate=value;
    if(value){
      this.form.get("date").disable();
    }
    else{
      this.form.get("date").enable();
    }
  };
  @Input('disableTime') set disableTime(value:boolean){
    this._disableDate=value;
    if(value){
      this.form.get("time").disable();
    }
    else{
      this.form.get("time").enable();
    }
  };
  @Input() label:string;
  @Input() max:Date;
  @Input() min:Date;
  @Input() showSec:boolean=false;
  @Output() dateChanged:EventEmitter<Date>=new EventEmitter();



  constructor(private fb:UntypedFormBuilder, private datePipe:DatePipe, public control:NgControl) {
    if (this.control != null) {
      this.control.valueAccessor = this;
    }
    this.form=this.fb.group({
      date:[{value:null}],
      time:[null]
    });
    console.log(this.control);
  }

  get errorState(): boolean {
    console.log("check");
    return this.control.invalid;
  }

  writeValue(value: Date): void {
    if(value!=null){
      this.value=value;
      this.form.setValue({
        date:value,
        time:this.datePipe.transform(value,this.showSec?"HH:mm:ss":"HH:mm")
      },{emitEvent:false})
    }
  }

  registerOnChange(onChange: any) {
    this.onChange=onChange;
  }

  registerOnTouched(onTouched: any) {
    this.onTouched = onTouched;
  }

  markAsTouched() {
    if (!this.touched) {
      this.onTouched();
      this.touched = true;
    }
  }

  setDisabledState(disabled: boolean) {
    this.disabled = disabled;
  }

  changeDate(event){
    const newDate=new Date(this.value.getTime());
    newDate.setDate(event.value.getDate());
    newDate.setMonth(event.value.getMonth());
    this.value=newDate;
    this.onChange(this.value);
    console.log("datechanged");
    this.dateChanged.emit(this.value);
    this.markAsTouched();
  }

  changeTime(event){
    const [hours,minutes,secs]=event.split(":");
    console.log(event.split(":"));
    const newDate=new Date(this.value.getTime());
    if(this.showSec){
      newDate.setHours(hours,minutes,secs);
    }
    else{
      newDate.setHours(hours,minutes);
    }
    this.value=newDate;
    this.onChange(this.value);
    this.markAsTouched();
  }

  onOverMidnight(dateChange:number){
    const newDate=new Date(this.value.getTime());
    if(newDate<=this.max && newDate>=this.min){
      newDate.setDate(newDate.getDate()+dateChange);
      this.value=newDate;
      this.onChange(this.value);
      this.form.patchValue({date:this.value});
    }
  }

}

export class MyErrorStateMatcher implements ErrorStateMatcher {

  isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean {
    console.log(control);
    console.log(form);
    return true;
  }
}

我试过用ErrorStateMatcher,但父表单的无效状态无法直接在子组件中访问,请问有没有优雅的方法实现父表单无效状态向自定义表单控件的传递?


解决方案

1. 实现感知父控件的ErrorStateMatcher

Angular Material 17调整了表单错误状态的匹配逻辑,需要让自定义Matcher能感知父控件状态。修改Matcher类:

export class ParentAwareErrorStateMatcher implements ErrorStateMatcher {
  constructor(private parentControl: NgControl) {}

  isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean {
    // 同时检查子控件自身状态和父控件的无效状态
    const childInvalid = !!(control?.invalid && (control.dirty || control.touched));
    const parentInvalid = !!(this.parentControl?.invalid && (this.parentControl.dirty || this.parentControl.touched));
    return childInvalid || parentInvalid;
  }
}

在组件中初始化该Matcher时传入当前父控件:

// 替换原有的matcher定义
matcher: ParentAwareErrorStateMatcher;

constructor(private fb: UntypedFormBuilder, private datePipe: DatePipe, public control: NgControl) {
  if (this.control != null) {
    this.control.valueAccessor = this;
  }
  this.form = this.fb.group({
    date: [{value: null}],
    time: [null]
  });
  // 初始化自定义Matcher
  this.matcher = new ParentAwareErrorStateMatcher(this.control);
}

2. 移除手动绑定的无效类

Angular Material 17会根据ErrorStateMatcher的结果自动应用mat-form-field-invalid类,删除模板中手动绑定的状态类:

<mat-form-field appearance="outline" class="no-padding no-underline">
  <mat-label>{{label}}</mat-label>
  <input matInput [matDatepicker]="picker" [max]="max" [min]="min" (dateChange)="changeDate($event)" readonly formControlName="date" (focus)="picker.open()" [errorStateMatcher]="matcher">
  <mat-datepicker #picker></mat-datepicker>
</mat-form-field>

3. 正确注册表单控件访问器

确保组件的providers中注册NG_VALUE_ACCESSOR,让Angular正确识别为表单控件:

@Component({
  selector: 'app-datetimepicker',
  templateUrl: './datetimepicker.component.html',
  styleUrls: ['./datetimepicker.component.scss'],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => DatetimepickerComponent),
      multi: true
    }
  ]
})

4. 同步子控件触摸状态到父控件

当子控件被触摸时,标记父控件为已触摸,确保错误状态正确触发:

markAsTouched() {
  if (!this.touched) {
    this.onTouched();
    this.touched = true;
    // 同步父控件的触摸状态
    this.control.control?.markAsTouched();
  }
}

5. 修复disableTime setter的逻辑错误

修正disableTime setter中错误赋值的变量:

@Input('disableTime') set disableTime(value:boolean){
  this._disableTime=value; // 原代码错误赋值为_disableDate,此处修正
  if(value){
    this.form.get("time").disable();
  }
  else{
    this.form.get("time").enable();
  }
};

内容的提问来源于stack exchange,提问作者Vartex05

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.23 06:22:04