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 永远不浮动,这是新手第一大坑。textarea 加 matInput 后还能配 cdkTextareaAutosize 自动长高(来自 CDK,无需额外包)。
appearance:fill 与 outline
v22 只有两外观(M2 时代的 legacy、standard 已移除):
<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-hint 与 mat-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];旧表单体系则通过可选注入NgControl(ControlValueAccessor路径)接入。一个组件服务两代表单,这正是 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 的定位、延迟与无障碍——它们会出现在你写的每一个列表页里。