Skip to content

Commit 23818f5

Browse files
authored
feat(form-field): expose label content element id for custom controls (#18528)
* feat(form-field): expose label element id for custom controls Currently, the form-field always creates a label element as sibling to projected form-field controls. For native controls, the label is associated with the controls using the `for` attribute. This doesn't work for custom controls which might not be based on native controls. e.g. the `mat-select`. In those cases, the appropriate aria attributes need to be applied with `aria-labelledby` that refers to the label content element. Since this is a common pattern for custom controls that don't use native controls, we need to expose the element id for the label content. Currently we already do this for the select, but just prefixed it with an underscore. This denotes it as private API while there is obviously a use-case for exposing this publicly. Best example is how the select _needs_ it. * docs: improve accessibility for custom form-field control guide Improves the accessibility for the control that is built as part of the custom form-field control guide.
1 parent 7b03d48 commit 23818f5

File tree

9 files changed

+93
-31
lines changed

9 files changed

+93
-31
lines changed

guides/creating-a-custom-form-field-control.md

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class MyTel {
2323
@Component({
2424
selector: 'example-tel-input',
2525
template: `
26-
<div [formGroup]="parts">
26+
<div role="group" [formGroup]="parts">
2727
<input class="area" formControlName="area" maxlength="3">
2828
<span>&ndash;</span>
2929
<input class="exchange" formControlName="exchange" maxlength="3">
@@ -45,7 +45,7 @@ class MyTel {
4545
}
4646
`],
4747
})
48-
class MyTelInput {
48+
export class MyTelInput {
4949
parts: FormGroup;
5050

5151
@Input()
@@ -85,7 +85,7 @@ a provider to our component so that the form field will be able to inject it as
8585
...
8686
providers: [{provide: MatFormFieldControl, useExisting: MyTelInput}],
8787
})
88-
class MyTelInput implements MatFormFieldControl<MyTel> {
88+
export class MyTelInput implements MatFormFieldControl<MyTel> {
8989
...
9090
}
9191
```
@@ -201,7 +201,7 @@ To resolve this, remove the `NG_VALUE_ACCESSOR` provider and instead set the val
201201
// },
202202
],
203203
})
204-
class MyTelInput implements MatFormFieldControl<MyTel> {
204+
export class MyTelInput implements MatFormFieldControl<MyTel> {
205205
constructor(
206206
...,
207207
@Optional() @Self() public ngControl: NgControl,
@@ -341,16 +341,20 @@ controlType = 'example-tel-input';
341341

342342
This method is used by the `<mat-form-field>` to specify the IDs that should be used for the
343343
`aria-describedby` attribute of your component. The method has one parameter, the list of IDs, we
344-
just need to apply the given IDs to our host element.
344+
just need to apply the given IDs to the element that represents our control.
345345

346-
```ts
347-
@HostBinding('attr.aria-describedby') describedBy = '';
346+
In our concrete example, these IDs would need to be applied to the group element.
348347

348+
```ts
349349
setDescribedByIds(ids: string[]) {
350350
this.describedBy = ids.join(' ');
351351
}
352352
```
353353

354+
```html
355+
<div role="group" [formGroup]="parts" [attr.aria-describedby]="describedBy">
356+
```
357+
354358
#### `onContainerClick(event: MouseEvent)`
355359

356360
This method will be called when the form field is clicked on. It allows your component to hook in
@@ -366,6 +370,41 @@ onContainerClick(event: MouseEvent) {
366370
}
367371
```
368372

373+
### Improving accessibility
374+
375+
Our custom form field control consists of multiple inputs that describe segments of a phone
376+
number. For accessibility purposes, we put those inputs as part of a `div` element with
377+
`role="group"`. This ensures that screen reader users can tell that all those inputs belong
378+
together.
379+
380+
One significant piece of information is missing for screen reader users though. They won't be able
381+
to tell what this input group represents. To improve this, we should add a label for the group
382+
element using either `aria-label` or `aria-labelledby`.
383+
384+
It's recommended to link the group to the label that is displayed as part of the parent
385+
`<mat-form-field>`. This ensures that explicitly specified labels (using `<mat-label>`) are
386+
actually used for labelling the control.
387+
388+
In our concrete example, we add an attribute binding for `aria-labelledby` and bind it
389+
to the label element id provided by the parent `<mat-form-field>`.
390+
391+
```typescript
392+
export class MyTelInput implements MatFormFieldControl<MyTel> {
393+
...
394+
395+
constructor(...
396+
@Optional() public parentFormField: MatFormField) {
397+
```
398+
399+
```html
400+
@Component({
401+
selector: 'example-tel-input',
402+
template: `
403+
<div role="group" [formGroup]="parts"
404+
[attr.aria-describedby]="describedBy"
405+
[attr.aria-labelledby]="parentFormField?.getLabelId()">
406+
```
407+
369408
### Trying it out
370409

371410
Now that we've fully implemented the interface, we're ready to try our component out! All we need to

src/components-examples/material-experimental/mdc-form-field/mdc-form-field-custom-control/example-tel-input-example.html

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
<div [formGroup]="parts" class="example-tel-input-container">
1+
<div role="group" class="example-tel-input-container"
2+
[formGroup]="parts"
3+
[attr.aria-labelledby]="_formField?.getLabelId()"
4+
[attr.aria-describedby]="describedBy">
25
<input
36
class="example-tel-input-element"
47
formControlName="area" size="3"

src/components-examples/material-experimental/mdc-form-field/mdc-form-field-custom-control/form-field-custom-control-example.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import {FocusMonitor} from '@angular/cdk/a11y';
22
import {coerceBooleanProperty} from '@angular/cdk/coercion';
3-
import {Component, ElementRef, Input, OnDestroy, Optional, Self} from '@angular/core';
4-
import {FormBuilder, FormGroup, ControlValueAccessor, NgControl, Validators} from '@angular/forms';
5-
import {MatFormFieldControl} from '@angular/material-experimental/mdc-form-field';
3+
import {Component, ElementRef, Inject, Input, OnDestroy, Optional, Self} from '@angular/core';
4+
import {ControlValueAccessor, FormBuilder, FormGroup, NgControl, Validators} from '@angular/forms';
5+
import {MatFormField, MatFormFieldControl} from '@angular/material-experimental/mdc-form-field';
6+
import {MAT_FORM_FIELD} from '@angular/material/form-field';
67
import {Subject} from 'rxjs';
78

89
/** @title Form field with custom telephone number input control. */
@@ -26,7 +27,6 @@ export class MyTel {
2627
host: {
2728
'[class.example-floating]': 'shouldLabelFloat',
2829
'[id]': 'id',
29-
'[attr.aria-describedby]': 'describedBy',
3030
}
3131
})
3232
export class MyTelInput implements ControlValueAccessor, MatFormFieldControl<MyTel>, OnDestroy {
@@ -93,6 +93,7 @@ export class MyTelInput implements ControlValueAccessor, MatFormFieldControl<MyT
9393
formBuilder: FormBuilder,
9494
private _focusMonitor: FocusMonitor,
9595
private _elementRef: ElementRef<HTMLElement>,
96+
@Optional() @Inject(MAT_FORM_FIELD) public _formField: MatFormField,
9697
@Optional() @Self() public ngControl: NgControl) {
9798

9899
this.parts = formBuilder.group({

src/components-examples/material/form-field/form-field-custom-control/example-tel-input-example.html

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
<div [formGroup]="parts" class="example-tel-input-container">
1+
<div role="group" class="example-tel-input-container"
2+
[formGroup]="parts"
3+
[attr.aria-labelledby]="_formField?.getLabelId()"
4+
[attr.aria-describedby]="describedBy">
25
<input class="example-tel-input-element"
36
formControlName="area" size="3"
47
maxLength="3"

src/components-examples/material/form-field/form-field-custom-control/form-field-custom-control-example.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,23 @@ import {coerceBooleanProperty} from '@angular/cdk/coercion';
33
import {
44
Component,
55
ElementRef,
6+
Inject,
67
Input,
78
OnDestroy,
89
Optional,
910
Self,
1011
ViewChild
1112
} from '@angular/core';
1213
import {
14+
AbstractControl,
15+
ControlValueAccessor,
1316
FormBuilder,
17+
FormControl,
1418
FormGroup,
15-
ControlValueAccessor,
1619
NgControl,
17-
Validators,
18-
FormControl,
19-
AbstractControl
20+
Validators
2021
} from '@angular/forms';
21-
import {MatFormFieldControl} from '@angular/material/form-field';
22+
import {MAT_FORM_FIELD, MatFormField, MatFormFieldControl} from '@angular/material/form-field';
2223
import {Subject} from 'rxjs';
2324

2425
/** @title Form field with custom telephone number input control. */
@@ -50,7 +51,6 @@ export class MyTel {
5051
host: {
5152
'[class.example-floating]': 'shouldLabelFloat',
5253
'[id]': 'id',
53-
'[attr.aria-describedby]': 'describedBy'
5454
}
5555
})
5656
export class MyTelInput
@@ -133,8 +133,9 @@ export class MyTelInput
133133
formBuilder: FormBuilder,
134134
private _focusMonitor: FocusMonitor,
135135
private _elementRef: ElementRef<HTMLElement>,
136-
@Optional() @Self() public ngControl: NgControl
137-
) {
136+
@Optional() @Inject(MAT_FORM_FIELD) public _formField: MatFormField,
137+
@Optional() @Self() public ngControl: NgControl) {
138+
138139
this.parts = formBuilder.group({
139140
area: [
140141
null,

src/material-experimental/mdc-form-field/form-field.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -191,11 +191,11 @@ export class MatFormField implements AfterViewInit, OnDestroy, AfterContentCheck
191191
}
192192
private _hintLabel = '';
193193

194-
// Unique id for the hint label.
195-
_hintLabelId = `mat-mdc-hint-${nextUniqueId++}`;
196-
197194
// Unique id for the internal form field label.
198-
_labelId = `mat-mdc-form-field-label-${nextUniqueId++}`;
195+
readonly _labelId = `mat-mdc-form-field-label-${nextUniqueId++}`;
196+
197+
// Unique id for the hint label.
198+
readonly _hintLabelId = `mat-mdc-hint-${nextUniqueId++}`;
199199

200200
/** State of the mat-hint and mat-error animations. */
201201
_subscriptAnimationState = '';
@@ -357,6 +357,13 @@ export class MatFormField implements AfterViewInit, OnDestroy, AfterContentCheck
357357
this._destroyed.complete();
358358
}
359359

360+
/**
361+
* Gets the id of the label element. If no label is present, returns `null`.
362+
*/
363+
getLabelId(): string|null {
364+
return this._hasFloatingLabel() ? this._labelId : null;
365+
}
366+
360367
/**
361368
* Gets an ElementRef for the element that a overlay attached to the form-field
362369
* should be positioned relative to.

src/material/form-field/form-field.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -219,10 +219,10 @@ export class MatFormField extends _MatFormFieldMixinBase
219219
private _hintLabel = '';
220220

221221
// Unique id for the hint label.
222-
_hintLabelId: string = `mat-hint-${nextUniqueId++}`;
222+
readonly _hintLabelId: string = `mat-hint-${nextUniqueId++}`;
223223

224-
// Unique id for the internal form field label.
225-
_labelId = `mat-form-field-label-${nextUniqueId++}`;
224+
// Unique id for the label element.
225+
readonly _labelId = `mat-form-field-label-${nextUniqueId++}`;
226226

227227
/**
228228
* Whether the label should always float, never float or float as the user types.
@@ -301,6 +301,13 @@ export class MatFormField extends _MatFormFieldMixinBase
301301
_defaults.hideRequiredMarker : false;
302302
}
303303

304+
/**
305+
* Gets the id of the label element. If no label is present, returns `null`.
306+
*/
307+
getLabelId(): string|null {
308+
return this._hasFloatingLabel() ? this._labelId : null;
309+
}
310+
304311
/**
305312
* Gets an ElementRef for the element that a overlay attached to the form-field should be
306313
* positioned relative to.

src/material/select/select.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1184,7 +1184,7 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit,
11841184
return null;
11851185
}
11861186

1187-
return this._parentFormField._labelId || null;
1187+
return this._parentFormField.getLabelId();
11881188
}
11891189

11901190
/** Determines the `aria-activedescendant` to be set on the host. */

tools/public_api_guard/material/form-field.d.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,11 @@ export declare class MatFormField extends _MatFormFieldMixinBase implements Afte
3535
_elementRef: ElementRef;
3636
_errorChildren: QueryList<MatError>;
3737
_hintChildren: QueryList<MatHint>;
38-
_hintLabelId: string;
38+
readonly _hintLabelId: string;
3939
_inputContainerRef: ElementRef;
4040
_labelChildNonStatic: MatLabel;
4141
_labelChildStatic: MatLabel;
42-
_labelId: string;
42+
readonly _labelId: string;
4343
_placeholderChild: MatPlaceholder;
4444
_prefixChildren: QueryList<MatPrefix>;
4545
_subscriptAnimationState: string;
@@ -66,6 +66,7 @@ export declare class MatFormField extends _MatFormFieldMixinBase implements Afte
6666
_shouldLabelFloat(): boolean;
6767
protected _validateControlChild(): void;
6868
getConnectedOverlayOrigin(): ElementRef;
69+
getLabelId(): string | null;
6970
ngAfterContentChecked(): void;
7071
ngAfterContentInit(): void;
7172
ngAfterViewInit(): void;

0 commit comments

Comments
 (0)