Skip to content

feat(form-field): expose label content element id for custom controls #18528

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jul 29, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 46 additions & 7 deletions guides/creating-a-custom-form-field-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ class MyTel {
@Component({
selector: 'example-tel-input',
template: `
<div [formGroup]="parts">
<div role="group" [formGroup]="parts">
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI: This is the recommended pattern for grouping multiple inputs: https://www.w3.org/TR/WCAG20-TECHS/ARIA17.html

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@crisbeto do we have this on date-range-input?

<input class="area" formControlName="area" maxlength="3">
<span>&ndash;</span>
<input class="exchange" formControlName="exchange" maxlength="3">
Expand All @@ -45,7 +45,7 @@ class MyTel {
}
`],
})
class MyTelInput {
export class MyTelInput {
parts: FormGroup;

@Input()
Expand Down Expand Up @@ -85,7 +85,7 @@ a provider to our component so that the form field will be able to inject it as
...
providers: [{provide: MatFormFieldControl, useExisting: MyTelInput}],
})
class MyTelInput implements MatFormFieldControl<MyTel> {
export class MyTelInput implements MatFormFieldControl<MyTel> {
...
}
```
Expand Down Expand Up @@ -201,7 +201,7 @@ To resolve this, remove the `NG_VALUE_ACCESSOR` provider and instead set the val
// },
],
})
class MyTelInput implements MatFormFieldControl<MyTel> {
export class MyTelInput implements MatFormFieldControl<MyTel> {
constructor(
...,
@Optional() @Self() public ngControl: NgControl,
Expand Down Expand Up @@ -341,16 +341,20 @@ controlType = 'example-tel-input';

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

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

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

```html
<div role="group" [formGroup]="parts" [attr.aria-describedby]="describedBy">
```

#### `onContainerClick(event: MouseEvent)`

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

### Improving accessibility

Our custom form field control consists of multiple inputs that describe segments of a phone
number. For accessibility purposes, we put those inputs as part of a `div` element with
`role="group"`. This ensures that screen reader users can tell that all those inputs belong
together.

One significant piece of information is missing for screen reader users though. They won't be able
to tell what this input group represents. To improve this, we should add a label for the group
element using either `aria-label` or `aria-labelledby`.

It's recommended to link the group to the label that is displayed as part of the parent
`<mat-form-field>`. This ensures that explicitly specified labels (using `<mat-label>`) are
actually used for labelling the control.

In our concrete example, we add an attribute binding for `aria-labelledby` and bind it
to the label element id provided by the parent `<mat-form-field>`.

```typescript
export class MyTelInput implements MatFormFieldControl<MyTel> {
...

constructor(...
@Optional() public parentFormField: MatFormField) {
```

```html
@Component({
selector: 'example-tel-input',
template: `
<div role="group" [formGroup]="parts"
[attr.aria-describedby]="describedBy"
[attr.aria-labelledby]="parentFormField?.getLabelId()">
```

### Trying it out

Now that we've fully implemented the interface, we're ready to try our component out! All we need to
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
<div [formGroup]="parts" class="example-tel-input-container">
<div role="group" class="example-tel-input-container"
[formGroup]="parts"
[attr.aria-labelledby]="_formField?.getLabelId()"
[attr.aria-describedby]="describedBy">
<input
class="example-tel-input-element"
formControlName="area" size="3"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import {FocusMonitor} from '@angular/cdk/a11y';
import {coerceBooleanProperty} from '@angular/cdk/coercion';
import {Component, ElementRef, Input, OnDestroy, Optional, Self} from '@angular/core';
import {FormBuilder, FormGroup, ControlValueAccessor, NgControl, Validators} from '@angular/forms';
import {MatFormFieldControl} from '@angular/material-experimental/mdc-form-field';
import {Component, ElementRef, Inject, Input, OnDestroy, Optional, Self} from '@angular/core';
import {ControlValueAccessor, FormBuilder, FormGroup, NgControl, Validators} from '@angular/forms';
import {MatFormField, MatFormFieldControl} from '@angular/material-experimental/mdc-form-field';
import {MAT_FORM_FIELD} from '@angular/material/form-field';
import {Subject} from 'rxjs';

/** @title Form field with custom telephone number input control. */
Expand All @@ -27,7 +28,6 @@ export class MyTel {
host: {
'[class.example-floating]': 'shouldLabelFloat',
'[id]': 'id',
'[attr.aria-describedby]': 'describedBy',
}
})
export class MyTelInput implements ControlValueAccessor, MatFormFieldControl<MyTel>, OnDestroy {
Expand Down Expand Up @@ -94,6 +94,7 @@ export class MyTelInput implements ControlValueAccessor, MatFormFieldControl<MyT
formBuilder: FormBuilder,
private _focusMonitor: FocusMonitor,
private _elementRef: ElementRef<HTMLElement>,
@Optional() @Inject(MAT_FORM_FIELD) public _formField: MatFormField,
@Optional() @Self() public ngControl: NgControl) {

this.parts = formBuilder.group({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
<div [formGroup]="parts" class="example-tel-input-container">
<div role="group" class="example-tel-input-container"
[formGroup]="parts"
[attr.aria-labelledby]="_formField?.getLabelId()"
[attr.aria-describedby]="describedBy">
<input class="example-tel-input-element"
formControlName="area" size="3"
maxLength="3"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,23 @@ import {coerceBooleanProperty} from '@angular/cdk/coercion';
import {
Component,
ElementRef,
Inject,
Input,
OnDestroy,
Optional,
Self,
ViewChild
} from '@angular/core';
import {
AbstractControl,
ControlValueAccessor,
FormBuilder,
FormControl,
FormGroup,
ControlValueAccessor,
NgControl,
Validators,
FormControl,
AbstractControl
Validators
} from '@angular/forms';
import {MatFormFieldControl} from '@angular/material/form-field';
import {MAT_FORM_FIELD, MatFormField, MatFormFieldControl} from '@angular/material/form-field';
import {Subject} from 'rxjs';

/** @title Form field with custom telephone number input control. */
Expand Down Expand Up @@ -51,7 +52,6 @@ export class MyTel {
host: {
'[class.example-floating]': 'shouldLabelFloat',
'[id]': 'id',
'[attr.aria-describedby]': 'describedBy'
}
})
export class MyTelInput
Expand Down Expand Up @@ -134,8 +134,9 @@ export class MyTelInput
formBuilder: FormBuilder,
private _focusMonitor: FocusMonitor,
private _elementRef: ElementRef<HTMLElement>,
@Optional() @Self() public ngControl: NgControl
) {
@Optional() @Inject(MAT_FORM_FIELD) public _formField: MatFormField,
@Optional() @Self() public ngControl: NgControl) {

this.parts = formBuilder.group({
area: [
null,
Expand Down
15 changes: 11 additions & 4 deletions src/material-experimental/mdc-form-field/form-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,11 +192,11 @@ export class MatFormField implements AfterViewInit, OnDestroy, AfterContentCheck
}
private _hintLabel = '';

// Unique id for the hint label.
_hintLabelId = `mat-mdc-hint-${nextUniqueId++}`;

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

// Unique id for the hint label.
readonly _hintLabelId = `mat-mdc-hint-${nextUniqueId++}`;

/** State of the mat-hint and mat-error animations. */
_subscriptAnimationState = '';
Expand Down Expand Up @@ -358,6 +358,13 @@ export class MatFormField implements AfterViewInit, OnDestroy, AfterContentCheck
this._destroyed.complete();
}

/**
* Gets the id of the label element. If no label is present, returns `null`.
*/
getLabelId(): string|null {
return this._hasFloatingLabel() ? this._labelId : null;
}

/**
* Gets an ElementRef for the element that a overlay attached to the form-field
* should be positioned relative to.
Expand Down
13 changes: 10 additions & 3 deletions src/material/form-field/form-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,10 +219,10 @@ export class MatFormField extends _MatFormFieldMixinBase
private _hintLabel = '';

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

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

/**
* Whether the label should always float, never float or float as the user types.
Expand Down Expand Up @@ -300,6 +300,13 @@ export class MatFormField extends _MatFormFieldMixinBase
_defaults.hideRequiredMarker : false;
}

/**
* Gets the id of the label element. If no label is present, returns `null`.
*/
getLabelId(): string|null {
return this._hasFloatingLabel() ? this._labelId : null;
}

/**
* Gets an ElementRef for the element that a overlay attached to the form-field should be
* positioned relative to.
Expand Down
2 changes: 1 addition & 1 deletion src/material/select/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1168,7 +1168,7 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit,
return null;
}

return this._parentFormField._labelId || null;
return this._parentFormField.getLabelId();
}

/** Determines the `aria-activedescendant` to be set on the host. */
Expand Down
5 changes: 3 additions & 2 deletions tools/public_api_guard/material/form-field.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,12 @@ export declare class MatFormField extends _MatFormFieldMixinBase implements Afte
_elementRef: ElementRef;
_errorChildren: QueryList<MatError>;
_hintChildren: QueryList<MatHint>;
_hintLabelId: string;
readonly _hintLabelId: string;
_inputContainerRef: ElementRef;
get _labelChild(): MatLabel;
_labelChildNonStatic: MatLabel;
_labelChildStatic: MatLabel;
_labelId: string;
readonly _labelId: string;
_placeholderChild: MatPlaceholder;
_prefixChildren: QueryList<MatPrefix>;
get _shouldAlwaysFloat(): boolean;
Expand All @@ -59,6 +59,7 @@ export declare class MatFormField extends _MatFormFieldMixinBase implements Afte
_shouldLabelFloat(): boolean;
protected _validateControlChild(): void;
getConnectedOverlayOrigin(): ElementRef;
getLabelId(): string | null;
ngAfterContentChecked(): void;
ngAfterContentInit(): void;
ngAfterViewInit(): void;
Expand Down