CHARLIE SAYS

查理如是说
DATE 2026-08-24
THEME
SERIES / ANGULAR / P-200 · Angular 高级教程

Angular Material 22+ 教程 38:Form Field 表单容器

mat-form-field 是 Material 表单的容器组件:统一管理 label、hint、error 的位置与浮动行为,让一打输入类控件(文本框、下拉、日期选择、滑杆)长得像一家人。它也是与 Signal Forms 配合时最常见的集成点。本篇讲它的结构、appearance 体系、提示与错误的展示规则,最后实现一个自定义 form field control——这是 Material 定制中仪式感最重的一个接口。

基本结构

<mat-form-field>
  <mat-label>任务标题</mat-label>
  <input matInput [formField]="form.title" />
  <mat-hint>2 到 80 个字符</mat-hint>
  <mat-error>标题必填</mat-error>
</mat-form-field>

四个成员的分工:

成员职责备注
mat-label浮动标签,聚焦/有值时上浮也可用原生 placeholder,但无浮动效果
matInput 控件实际输入元素指令同时承担”向容器上报状态”的职责
mat-hint底部辅助文字align="end" 靠右,可与左侧 hint 并存
mat-error底部错误文字有错误且该显示时替换 hint 的位置

matInput 不是装饰——它是 MatInput 指令的选择器。忘写它,mat-form-field 会认为里面没有表单控件,label 永远不浮动,这是新手第一大坑。textareamatInput 后还能配 cdkTextareaAutosize 自动长高(来自 CDK,无需额外包)。

appearance:fill 与 outline

v22 只有两外观(M2 时代的 legacystandard 已移除):

<mat-form-field appearance="fill">
  <mat-label>填充风格</mat-label>
  <input matInput />
</mat-form-field>

<mat-form-field appearance="outline">
  <mat-label>描边风格</mat-label>
  <input matInput />
</mat-form-field>
  • fill:背景填充 + 底部线,视觉重量大,适合密集表单里强调”可输入区域”
  • outline:描边 + 浮动标签切断边线,浅色底色下更清爽,是多数中后台的选择

全局默认外观用 token 或 DI 配置,不必每个实例写:

import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field';

bootstrapApplication(AppComponent, {
  providers: [
    { provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, useValue: { appearance: 'outline' } },
  ],
});

外观的尺寸细节(容器高度、圆角)都可以按第 37 篇的方式覆盖组件 token,例如 --mat-form-field-outline-container-shape

hint 与 error:一个位置,两个租客

mat-hintmat-error 共享底部的 subscript 区域,同一时刻只显示一个,error 优先

<mat-form-field>
  <mat-label>备注</mat-label>
  <textarea matInput cdkTextareaAutosize [formField]="form.notes"></textarea>

  <mat-hint align="start">支持 Markdown</mat-hint>
  <mat-hint align="end">{{ form.notes.value().length }} / 500</mat-hint>

  <mat-error>备注不能超过 500 字</mat-error>
</mat-form-field>

两侧 hint 可以并存(align="start" 默认)。错误显示的条件由控件的”错误可见性”决定——这正是与 Signal Forms 配合时要注意的地方。

与 Signal Forms 配合

第 25 篇的 [formField] 直接用在 matInput 上:

import { Component } from '@angular/core';
import { form, validate, required, minLength, maxLength } from '@angular/forms';
import { MatFormField } from '@angular/material/form-field';
import { MatInput } from '@angular/material/input';

@Component({
  selector: 'app-task-form',
  imports: [MatFormField, MatInput],
  templateUrl: './task-form.component.html',
})
export class TaskFormComponent {
  protected readonly form = form({
    title: validate(required(), minLength(2), maxLength(80)),
    notes: validate(maxLength(500)),
  });
}
<mat-form-field appearance="outline" class="full">
  <mat-label>任务标题</mat-label>
  <input matInput [formField]="form.title" autocomplete="off" />

  @if (form.title.touched() && form.title.getError('required')) {
    <mat-error>标题必填</mat-error>
  } @else if (form.title.touched() && form.title.getError('minLength'); as err) {
    <mat-error>至少 {{ err.required }} 个字符,当前 {{ err.actual }}</mat-error>
  }
</mat-form-field>

要点:

  • mat-error 要自己用 @if 控制。Signal Forms 的字段没有 NgControl 那套 invalid && touched 属性可供容器查询,错误何时铺开由你的信号表达式决定——好处是”失焦才显示 / 提交才显示 / 立即显示”的策略完全归你(配合 markAsTouched() 的递归语义,见第 25 篇)
  • 提交时统一铺开错误依然一行:this.form.markAsTouched()
  • 禁用状态走 [disabled] 绑定或字段的 disabled() 声明,容器会同步渲染禁用样式

prefix 与 suffix:前后缀

输入框前后可以挂图标、单位、按钮:

<mat-form-field appearance="outline">
  <mat-label>密码</mat-label>
  <mat-icon matPrefix>lock</mat-icon>
  <input matInput [type]="reveal() ? 'text' : 'password'" [formField]="form.password" />
  <button matIconSuffix type="button" (click)="reveal.set(!reveal())" [attr.aria-label]="reveal() ? '隐藏密码' : '显示密码'">
    {{ reveal() ? '隐藏' : '显示' }}
  </button>
</mat-form-field>
readonly reveal = signal(false);

matPrefix / matSuffix(以及图标专用的 matIconPrefix / matIconSuffix)是指令而非组件,放在要挂载的元素上。密码可见性切换是无障碍检查的重点:按钮必须有 aria-label,切换用信号驱动,不要直接操作 DOM。

自定义 Form Field Control

场景:金额输入、坐标输入、本例的”数量步进器”——想在 mat-form-field 里放一个完全自绘的控件。容器需要知道控件的值、焦点、空态等状态才能渲染浮动 label 与下划线,MatFormFieldControl<T> 就是这个契约:

import { Component, DestroyRef, Subject, forwardRef, signal } from '@angular/core';
import { MatFormFieldControl } from '@angular/material/form-field';
import { ControlValueAccessor, NgControl } from '@angular/forms';

@Component({
  selector: 'app-quantity-stepper',
  providers: [
    { provide: MatFormFieldControl, useExisting: forwardRef(() => QuantityStepperComponent) },
  ],
  template: `
    <div class="stepper" (focusin)="focused = true" (focusout)="onBlur()">
      <button type="button" (click)="step(-1)" aria-label="减少">−</button>
      <input
        type="text"
        inputmode="numeric"
        [value]="value"
        (change)="onInput($any($event.target).value)"
        aria-label="数量"
      />
      <button type="button" (click)="step(1)" aria-label="增加">+</button>
    </div>
  `,
})
export class QuantityStepperComponent implements MatFormFieldControl<number> {
  // 容器读取的核心状态
  value: number | null = 0;
  readonly stateChanges = new Subject<void>();
  readonly controlType = 'app-quantity-stepper';
  id = `app-quantity-stepper-${QuantityStepperComponent.nextId++}`;
  private static nextId = 0;

  label = '数量';
  required = false;
  disabled = false;
  errorState = false;
  focused = false;
  describedBy = '';

  get empty(): boolean {
    return this.value === null || this.value === 0;
  }

  // 点击容器 label 区域时把焦点移进控件
  onContainerClick(): void {
    this.focusInput();
  }

  setDescribedByIds(ids: string[]): void {
    this.describedBy = ids.join(' ');
  }

  private onBlur(): void {
    this.focused = false;
    this.stateChanges.next();
  }

  private step(delta: number): void {
    if (this.disabled) {
      return;
    }
    this.value = Math.max(0, (this.value ?? 0) + delta);
    this.stateChanges.next();
  }

  private onInput(raw: string): void {
    const parsed = Number(raw);
    this.value = Number.isFinite(parsed) ? Math.max(0, Math.trunc(parsed)) : null;
    this.stateChanges.next();
  }

  private focusInput(): void { /* 聚焦内部 input */ }
}

使用时直接放进容器,label、hint、错误位置全部自动接管:

<mat-form-field appearance="outline">
  <mat-label>购买数量</mat-label>
  <app-quantity-stepper></app-quantity-stepper>
  <mat-hint>最少 1 件</mat-hint>
</mat-form-field>

接口成员速查:

成员容器用它做什么
value / stateChanges判断空态(label 是否浮动);状态变化通知
focused下划线/边框高亮
empty直接给出空态判定(不实现时容器看 value)
required / disabled渲染必填星号与禁用样式
errorState控制错误样式切换
onContainerClick点击 label 区域转发焦点
setDescribedByIds把 hint/error 的 aria 描述挂到控件

两条实践建议:

  • stateChanges.next() 是心跳。任何容器关心的状态变了都要叫它,漏掉的表现通常是”label 不浮动/错误不消失”这类灵异现象
  • 需要接入表单体系时再叠加协议。给组件加一个 value = model(0)touch 输出,它就同时是 FormValueControl(第 25 篇),可直接 [formField];旧表单体系则通过可选注入 NgControlControlValueAccessor 路径)接入。一个组件服务两代表单,这正是 v22 双向兼容设计的用武之地

常见坑汇总

现象原因
label 永远不浮动控件忘加 matInput(或不是 MatFormFieldControl)
hint 与 error 同时出现不会——共享 subscript 区域,error 优先;看到”同时出现”多半是两个都写成了 hint
outline 模式 label 切口错位自定义字体行高影响,检查 typography token 是否被局部覆盖
自定义控件禁用态不生效只改了内部按钮,没同步 disabled 成员并 stateChanges.next()
错误信息不显示@if 条件里漏了 touched(),或提交逻辑没调 markAsTouched()

表单容器之外,Material 还有两个小而高频的组件:图标与提示。下一篇讲 mat-icon 的字体/SVG 双模式、注册、CSP,以及 mat-tooltip 的定位、延迟与无障碍——它们会出现在你写的每一个列表页里。

系列导航

← 算法 038:字符串匹配:模式预处理:BM 算法 (Boyer-Moore) 目录 算法 039:字符串匹配:文本预处理:后缀树(Suffix Tree) →
← 返回文章列表