创建自定义表单字段(form field)控件

你可以创建能用于 <mat-form-field> 中的自定义表单字段控件。 这种技术能让你创建这样一个控件:它与某个表单字段共享许多常见的行为,但再添加一些额外的逻辑。

比如,在这份指南中,你将学会如何创建一个用来输入美国的电话号码的自定义输入框,它能和 <mat-form-field> 协同工作。 其最终效果如下:

要学习如何构建自定义表单字段控件,我们先从一个简单的输入框组件开始,我们要让它能用于表单字段中。 比如,一个电话号码输入框应该能把号码拆分后放进它的各个输入属性中。(注意,我们并不打算把它做成一个健壮的组件,只想把它作成学习的起点。)

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>&ndash;</span>
      <input class="exchange" formControlName="exchange" maxlength="3">
      <span>&ndash;</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': '',
    });
  }
}

第一步把我们的组件作为 MatFormFieldControl 接口的实现提供出来,而 <mat-form-field> 知道该如何与该接口协作。 要做到这一点,我们要创建一个自己的类来实现 MatFormFieldControl。由于它是一个泛型接口,我们需要包含一个类型参数来标记出这个控件支持的数据类型,在这里也就是 MyTel。然后我们为这个组件添加一个提供者,以便表单字段(form-field)能把它作为 MatFormFieldControl 注入进去。

@Component({
  ...
  providers: [{provide: MatFormFieldControl, useExisting: MyTelInput}],
})
export class MyTelInput implements MatFormFieldControl<MyTel> {
  ...
}

这将准备好我们的组件,以便它能和 <mat-form-field> 协作。接下来我们还要实现该接口中声明的各个方法和属性。 要深入了解 MatFormFieldControl 接口,参见表单字段的 API 文档

该属性能让别人设置或获取我们这个控件的值。它的类型应该与我们要实现的 MatFormFieldControl 中的类型参数保持一致。 由于我们的组件已经有了 value 属性,所以这里不用再做任何事。

由于 <mat-form-field> 使用 OnPush 变更检查策略,所以当这个表单字段控件中发生变化时,我们需要通知表单字段执行变更检测。我们可以用 stateChanges 属性实现这一点。到目前为止,表单字段唯一需要知道的是值变化的时机。当值变化时,我们就要发出 stateChanges 流,后面我们还会发现更多的时间点。当我们的组件被销毁时,我们一定要结束(complete())这个 stateChanges 流。

stateChanges = new Subject<void>();

set value(tel: MyTel | null) {
  ...
  this.stateChanges.next();
}

ngOnDestroy() {
  this.stateChanges.complete();
}

该属性应该返回元素在组件模板中的 ID,这样 <mat-form-field> 才能把它所有的标签和提示都与我们的控件关联起来。 这种情况下,我们可以使用宿主元素,并自动为它生成一个具有唯一性的 ID。

static nextId = 0;

@HostBinding() id = `example-tel-input-${MyTelInput.nextId++}`;

该属性让我们能告诉 <mat-form-field> 该把什么用作占位符。在这个例子中,我们的做法和 matInput<mat-select> 一样,允许用户通过 @Input() 来指定它。 由于占位符的值以后可能还会改变,所以,当它变化时,我们一定要通过 stateChanges 流发出事件,以触发表单字段的变更检测。

@Input()
get placeholder() {
  return this._placeholder;
}
set placeholder(plh) {
  this._placeholder = plh;
  this.stateChanges.next();
}
private _placeholder: string;

该属性能为表单字段控件指定一个 @angular/forms 中的表单控件,以绑定到本控件。由于我们还没有把本组件实现为 ControlValueAccessor,所以我们先把它设置为 null

ngControl: NgControl = null;

你还要实现 ControlValueAccessor,来让你的组件跟 formControlngModel 协同工作。要想实现 ControlValueAccessor,你就要获得一个关联到此控件的 NgControl,并把它公开。

最简单的方式是把它添加为构造函数中的一个公共属性,并交给依赖注入体系来处理它:

constructor(
  ...,
  @Optional() @Self() public ngControl: NgControl,
  ...,
) { }

注意,如果你的组件实现了 ControlValueAccessor,那么它可能已经作为 NG_VALUE_ACCESSOR 提供出去了(在组件装饰器的 providers 部分,或模块声明中)。如果是这样,可能会导致cannot instantiate cyclic dependency(不能实例化循环依赖)错误。

要解决这个问题,请移除 NG_VALUE_ACCESSOR 提供者,改为直接设置 Value Accessor 的值:

@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;
    }
  }
}

要深入了解 ControlValueAccessor,参见其 API 文档

该属性表示该表单字段控件是否要被视为有焦点状态。当处于有焦点状态时,表单字段会显示一个实下划线。 对于这个组件,我们希望当其中的任何一个输入框拥有焦点时,我们就认为该组件拥有焦点。我们可以使用 focusinfocusout 事件来轻松地检查它。另外,当焦点状态发生变化时,别忘了在 stateChanges 流上发出事件,以便触发变更检测。

除了更新焦点状态之外,我们还使用 focusinfocusout 方法来更新组件内部的已接触(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();
  }
}

该属性表示这个表单字段控件是否空的。对于这个控件来说,当它所有的部分都是空的时,我们才认为它是空的。

get empty() {
  let n = this.parts.value;
  return !n.area && !n.exchange && !n.subscriber;
}

该属性表示是否应该把标签显示在浮动位置。它和 matInput 在输入框有焦点或非空时上浮占位符的逻辑是一样的。 当不浮动时,该占位符将会遮住我们的控件,所以这种情况下我们应该隐藏 - 字符。

@HostBinding('class.floating')
get shouldLabelFloat() {
  return this.focused || !this.empty;
}
span {
  opacity: 0;
  transition: opacity 200ms;
}
:host.floating span {
  opacity: 1;
}

该属性表示此输入框是必填的。<mat-form-field> 会据此给占位符添加必填项指示器。 同样的,当必填状态发生了变化时,我们要确保触发变更检测。

@Input()
get required() {
  return this._required;
}
set required(req: BooleanInput) {
  this._required = coerceBooleanProperty(req);
  this.stateChanges.next();
}
private _required = false;

该属性告诉表单字段它何时应该处于禁用状态。除了向表单字段汇报正确的状态之外,我们还要据此来设置内部各个独立控件的禁用状态。

@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;

该属性表示相关的 NgControl 是否处于错误状态。在这个例子中,如果输入无效,且组件已经被碰过,我们就会显示一个错误。

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 parent = this._parentFormGroup || this.parentForm;

  const oldState = this.errorState;
  const newState = (this.ngControl?.invalid || this.parts.invalid) && (this.touched || parent.submitted);

  if (oldState !== newState) {
    this.errorState = newState;
    this.stateChanges.next();
  }
}

请记住,updateErrorState() 应该只有尽可能少的逻辑以避免性能问题。

该属性可以让我们指定一个具有唯一性的字符串,以便在表单字段中表示该控件的类型。 <mat-form-field> 将会据此添加一个附加类,可用于为包含指定控件类型的 <mat-form-field> 指定一些特殊样式。 在这个例子中,我们要用 my-tel-input 作为我们的控件类型,这将给包含它的表单字段加上 mat-form-field-my-tel-input 类。

controlType = 'example-tel-input';

此方法被 <mat-form-field> 用于设置元素的 ID,这些 ID 会被控件的 aria-describedby 属性使用。这些 ID 会由表单字段(form field)控制,用于提示(hints)或错误(errors)的有条件显示,并且应该反映到控件的 aria-describedby 属性中,以提升无障碍化体验。

每当控件的状态发生变化时,都会调用 setDescribedByIds 方法。自定义控件需要实现这个方法,并根据指定的元素 id 来更新 aria-describedby 属性。下面的例子演示了要如何达成这一目标。

注意,默认情况下该方法不在乎元素上通过 aria-describedby 属性手动设置的 ID。为了防止你的控件意外覆盖由控件消费者指定的现有 ID,可以创建一个名为 userAriaDescribedby 的输入属性,代码如下:

@Input('aria-describedby') userAriaDescribedBy: string;

然后,表单字段会取得用户指定的 aria-describedby ID,并确保每次调用 setDescribedByIds 时,都会把它们和为提示或错误提供的那些 ID 合并。

setDescribedByIds(ids: string[]) {
  const controlElement = this._elementRef.nativeElement
    .querySelector('.example-tel-input-container')!;
  controlElement.setAttribute('aria-describedby', ids.join(' '));
}

当用户点击表单字段时就会调用该方法。它让你的组件可以按需挂接并处理点击事件。该方法只有一个参数,也就是点击时的 MouseEvent。 在这个例子中,如果用户没有直接点击某一个 <input>,则我们只要对第一个 <input> 设置焦点就可以了。

onContainerClick(event: MouseEvent) {
  if ((event.target as Element).tagName.toLowerCase() != 'input') {
    this._elementRef.nativeElement.querySelector('input').focus();
  }
}

我们的自定义表单字段控件由多个用于描述电话号码段的输入控件组成。为了提升无障碍性,我们把这些输入作为带有 role="group" 属性的 div 元素的一部分。这可以确保屏幕阅读器用户知道所有这些输入控件都是一起的。

屏幕阅读器用户却缺少一条很重要的信息。他们无法分辨这个输入组所代表的含义。为了改善这一点,我们应该使用 aria-label 或者 aria-labelledby 为这个分组元素添加一个标签。

我们建议把该标签链接到这个组,将其作为父元素 <mat-form-field> 的一部分。这可以确保显式指定的标签(使用 <mat-label> )确实被用作了该控件的标签。

在下面的具体例子中,我们添加了一个到 aria-labelledby 属性的绑定,并把它绑定到由父 <mat-form-field> 提供的标签元素的 id。

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()">

现在,我们已经完整的实现了该接口,来试试它!我们要做的一切就是把它扔进 <mat-form-field> 中:

<mat-form-field>
  <example-tel-input></example-tel-input>
</mat-form-field>

我们还获得了 <mat-form-field> 的所有特性,比如上浮占位符、前缀、后缀、提示和错误(前提是我们给了表单字段一个 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>