Skip to content

Commit c8f03c7

Browse files
authored
feat(material-experimental/mdc-autocomplete): implement MDC-based mat-autocomplete (#20247)
* Moves all of the autocomplete logic into base classes so that it can be reused between the standard and MDC components. * Re-implements `mat-autocomplete` using the logic from the existing one and the styling from MDC. The MDC-based autocomplete behaves identically to the existing one, with the only minor difference being that MDC one fixes a long-standing issue where we expect a hardcoded height for each of the options. It was easier to fix the bug and add logic to support arbitrary option heights than to add more logic to account for MDC's styles.
1 parent cb8de61 commit c8f03c7

34 files changed

+3988
-194
lines changed

.github/CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@
144144
/src/dev-app/baseline/** @mmalerba
145145
/src/dev-app/bottom-sheet/** @jelbourn @crisbeto
146146
/src/dev-app/button-toggle/** @jelbourn
147+
/src/dev-app/mdc-autocomplete/** @crisbeto
147148
/src/dev-app/button/** @jelbourn
148149
/src/dev-app/card/** @jelbourn
149150
/src/dev-app/cdk-experimental-listbox/** @jelbourn @nielsr98

src/dev-app/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ ng_module(
4646
"//src/dev-app/input",
4747
"//src/dev-app/list",
4848
"//src/dev-app/live-announcer",
49+
"//src/dev-app/mdc-autocomplete",
4950
"//src/dev-app/mdc-button",
5051
"//src/dev-app/mdc-card",
5152
"//src/dev-app/mdc-checkbox",

src/dev-app/dev-app/dev-app-layout.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export class DevAppLayout {
7878
{name: 'Typography', route: '/typography'},
7979
{name: 'Virtual Scrolling', route: '/virtual-scroll'},
8080
{name: 'YouTube Player', route: '/youtube-player'},
81+
{name: 'MDC Autocomplete', route: '/mdc-autocomplete'},
8182
{name: 'MDC Button', route: '/mdc-button'},
8283
{name: 'MDC Card', route: '/mdc-card'},
8384
{name: 'MDC Checkbox', route: '/mdc-checkbox'},

src/dev-app/dev-app/routes.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ export const DEV_APP_ROUTES: Routes = [
6969
path: 'menubar',
7070
loadChildren: 'menubar/mat-menubar-demo-module#MatMenuBarDemoModule'
7171
},
72+
{
73+
path: 'mdc-autocomplete',
74+
loadChildren: 'mdc-autocomplete/mdc-autocomplete-demo-module#MdcAutocompleteDemoModule'
75+
},
7276
{path: 'mdc-button', loadChildren: 'mdc-button/mdc-button-demo-module#MdcButtonDemoModule'},
7377
{path: 'mdc-card', loadChildren: 'mdc-card/mdc-card-demo-module#MdcCardDemoModule'},
7478
{
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
load("//tools:defaults.bzl", "ng_module", "sass_binary")
2+
3+
package(default_visibility = ["//visibility:public"])
4+
5+
ng_module(
6+
name = "mdc-autocomplete",
7+
srcs = glob(["**/*.ts"]),
8+
assets = [
9+
"mdc-autocomplete-demo.html",
10+
":mdc_autocomplete_demo_scss",
11+
],
12+
deps = [
13+
"//src/material-experimental/mdc-autocomplete",
14+
"//src/material-experimental/mdc-button",
15+
"//src/material-experimental/mdc-card",
16+
"//src/material-experimental/mdc-form-field",
17+
"//src/material-experimental/mdc-input",
18+
"@npm//@angular/forms",
19+
"@npm//@angular/router",
20+
],
21+
)
22+
23+
sass_binary(
24+
name = "mdc_autocomplete_demo_scss",
25+
src = "mdc-autocomplete-demo.scss",
26+
)
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {CommonModule} from '@angular/common';
10+
import {NgModule} from '@angular/core';
11+
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
12+
import {MatAutocompleteModule} from '@angular/material-experimental/mdc-autocomplete';
13+
import {MatButtonModule} from '@angular/material-experimental/mdc-button';
14+
import {MatCardModule} from '@angular/material-experimental/mdc-card';
15+
import {MatFormFieldModule} from '@angular/material-experimental/mdc-form-field';
16+
import {MatInputModule} from '@angular/material-experimental/mdc-input';
17+
import {RouterModule} from '@angular/router';
18+
import {MdcAutocompleteDemo} from './mdc-autocomplete-demo';
19+
20+
@NgModule({
21+
imports: [
22+
CommonModule,
23+
FormsModule,
24+
MatAutocompleteModule,
25+
MatButtonModule,
26+
MatCardModule,
27+
MatFormFieldModule,
28+
MatInputModule,
29+
ReactiveFormsModule,
30+
RouterModule.forChild([{path: '', component: MdcAutocompleteDemo}]),
31+
],
32+
declarations: [MdcAutocompleteDemo],
33+
})
34+
export class MdcAutocompleteDemoModule {
35+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
Space above cards: <input type="number" [formControl]="topHeightCtrl">
2+
<div [style.height.px]="topHeightCtrl.value"></div>
3+
<div class="demo-autocomplete">
4+
<mat-card *ngIf="(reactiveStates | async) as tempStates">
5+
Reactive length: {{ tempStates?.length }}
6+
<div>Reactive value: {{ stateCtrl.value | json }}</div>
7+
<div>Reactive dirty: {{ stateCtrl.dirty }}</div>
8+
9+
<mat-form-field>
10+
<mat-label>State</mat-label>
11+
<input matInput [matAutocomplete]="reactiveAuto" [formControl]="stateCtrl">
12+
<mat-autocomplete #reactiveAuto="matAutocomplete" [displayWith]="displayFn">
13+
<mat-option *ngFor="let state of tempStates" [value]="state">
14+
<span>{{ state.name }}</span>
15+
<span class="demo-secondary-text"> ({{ state.code }}) </span>
16+
</mat-option>
17+
</mat-autocomplete>
18+
</mat-form-field>
19+
20+
<mat-card-actions>
21+
<button mat-button (click)="stateCtrl.reset()">RESET</button>
22+
<button mat-button (click)="stateCtrl.setValue(states[10])">SET VALUE</button>
23+
<button mat-button (click)="stateCtrl.enabled ? stateCtrl.disable() : stateCtrl.enable()">
24+
TOGGLE DISABLED
25+
</button>
26+
</mat-card-actions>
27+
28+
</mat-card>
29+
30+
<mat-card>
31+
32+
<div>Template-driven value (currentState): {{ currentState }}</div>
33+
<div>Template-driven dirty: {{ modelDir ? modelDir.dirty : false }}</div>
34+
35+
<!-- Added an ngIf below to test that autocomplete works with ngIf -->
36+
<mat-form-field *ngIf="true">
37+
<mat-label>State</mat-label>
38+
<input matInput [matAutocomplete]="tdAuto" [(ngModel)]="currentState"
39+
(ngModelChange)="tdStates = filterStates(currentState)" [disabled]="tdDisabled">
40+
<mat-autocomplete #tdAuto="matAutocomplete">
41+
<mat-option *ngFor="let state of tdStates" [value]="state.name">
42+
<span>{{ state.name }}</span>
43+
</mat-option>
44+
</mat-autocomplete>
45+
</mat-form-field>
46+
47+
<mat-card-actions>
48+
<button mat-button (click)="modelDir.reset()">RESET</button>
49+
<button mat-button (click)="currentState='California'">SET VALUE</button>
50+
<button mat-button (click)="tdDisabled=!tdDisabled">
51+
TOGGLE DISABLED
52+
</button>
53+
</mat-card-actions>
54+
55+
</mat-card>
56+
57+
<mat-card>
58+
<div>Option groups (currentGroupedState): {{ currentGroupedState }}</div>
59+
60+
<mat-form-field>
61+
<mat-label>State</mat-label>
62+
<input
63+
matInput
64+
[matAutocomplete]="groupedAuto"
65+
[(ngModel)]="currentGroupedState"
66+
(ngModelChange)="filteredGroupedStates = filterStateGroups(currentGroupedState)">
67+
</mat-form-field>
68+
</mat-card>
69+
</div>
70+
71+
<mat-autocomplete #groupedAuto="matAutocomplete">
72+
<mat-optgroup *ngFor="let group of filteredGroupedStates"
73+
[label]="'States starting with ' + group.letter">
74+
<mat-option *ngFor="let state of group.states" [value]="state.name">{{ state.name }}</mat-option>
75+
</mat-optgroup>
76+
</mat-autocomplete>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
.demo-autocomplete {
2+
display: flex;
3+
flex-flow: row wrap;
4+
5+
.mat-mdc-card {
6+
width: 400px;
7+
margin: 24px;
8+
padding: 16px;
9+
}
10+
11+
.mat-mdc-form-field {
12+
margin-top: 16px;
13+
min-width: 200px;
14+
max-width: 100%;
15+
}
16+
}
17+
18+
.demo-secondary-text {
19+
color: rgba(0, 0, 0, 0.54);
20+
margin-left: 8px;
21+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {Component, ViewChild} from '@angular/core';
10+
import {FormControl, NgModel} from '@angular/forms';
11+
import {Observable} from 'rxjs';
12+
import {map, startWith} from 'rxjs/operators';
13+
14+
15+
export interface State {
16+
code: string;
17+
name: string;
18+
}
19+
20+
export interface StateGroup {
21+
letter: string;
22+
states: State[];
23+
}
24+
25+
@Component({
26+
selector: 'mdc-autocomplete-demo',
27+
templateUrl: 'mdc-autocomplete-demo.html',
28+
styleUrls: ['mdc-autocomplete-demo.css']
29+
})
30+
export class MdcAutocompleteDemo {
31+
stateCtrl: FormControl;
32+
currentState = '';
33+
currentGroupedState = '';
34+
topHeightCtrl = new FormControl(0);
35+
36+
reactiveStates: Observable<State[]>;
37+
tdStates: State[];
38+
39+
tdDisabled = false;
40+
41+
@ViewChild(NgModel) modelDir: NgModel;
42+
43+
groupedStates: StateGroup[];
44+
filteredGroupedStates: StateGroup[];
45+
states: State[] = [
46+
{code: 'AL', name: 'Alabama'},
47+
{code: 'AK', name: 'Alaska'},
48+
{code: 'AZ', name: 'Arizona'},
49+
{code: 'AR', name: 'Arkansas'},
50+
{code: 'CA', name: 'California'},
51+
{code: 'CO', name: 'Colorado'},
52+
{code: 'CT', name: 'Connecticut'},
53+
{code: 'DE', name: 'Delaware'},
54+
{code: 'FL', name: 'Florida'},
55+
{code: 'GA', name: 'Georgia'},
56+
{code: 'HI', name: 'Hawaii'},
57+
{code: 'ID', name: 'Idaho'},
58+
{code: 'IL', name: 'Illinois'},
59+
{code: 'IN', name: 'Indiana'},
60+
{code: 'IA', name: 'Iowa'},
61+
{code: 'KS', name: 'Kansas'},
62+
{code: 'KY', name: 'Kentucky'},
63+
{code: 'LA', name: 'Louisiana'},
64+
{code: 'ME', name: 'Maine'},
65+
{code: 'MD', name: 'Maryland'},
66+
{code: 'MA', name: 'Massachusetts'},
67+
{code: 'MI', name: 'Michigan'},
68+
{code: 'MN', name: 'Minnesota'},
69+
{code: 'MS', name: 'Mississippi'},
70+
{code: 'MO', name: 'Missouri'},
71+
{code: 'MT', name: 'Montana'},
72+
{code: 'NE', name: 'Nebraska'},
73+
{code: 'NV', name: 'Nevada'},
74+
{code: 'NH', name: 'New Hampshire'},
75+
{code: 'NJ', name: 'New Jersey'},
76+
{code: 'NM', name: 'New Mexico'},
77+
{code: 'NY', name: 'New York'},
78+
{code: 'NC', name: 'North Carolina'},
79+
{code: 'ND', name: 'North Dakota'},
80+
{code: 'OH', name: 'Ohio'},
81+
{code: 'OK', name: 'Oklahoma'},
82+
{code: 'OR', name: 'Oregon'},
83+
{code: 'PA', name: 'Pennsylvania'},
84+
{code: 'RI', name: 'Rhode Island'},
85+
{code: 'SC', name: 'South Carolina'},
86+
{code: 'SD', name: 'South Dakota'},
87+
{code: 'TN', name: 'Tennessee'},
88+
{code: 'TX', name: 'Texas'},
89+
{code: 'UT', name: 'Utah'},
90+
{code: 'VT', name: 'Vermont'},
91+
{code: 'VA', name: 'Virginia'},
92+
{code: 'WA', name: 'Washington'},
93+
{code: 'WV', name: 'West Virginia'},
94+
{code: 'WI', name: 'Wisconsin'},
95+
{code: 'WY', name: 'Wyoming'},
96+
];
97+
98+
constructor() {
99+
this.tdStates = this.states;
100+
this.stateCtrl = new FormControl({code: 'CA', name: 'California'});
101+
this.reactiveStates = this.stateCtrl.valueChanges
102+
.pipe(
103+
startWith(this.stateCtrl.value),
104+
map(val => this.displayFn(val)),
105+
map(name => this.filterStates(name))
106+
);
107+
108+
this.filteredGroupedStates = this.groupedStates =
109+
this.states.reduce<StateGroup[]>((groups, state) => {
110+
let group = groups.find(g => g.letter === state.name[0]);
111+
112+
if (!group) {
113+
group = { letter: state.name[0], states: [] };
114+
groups.push(group);
115+
}
116+
117+
group.states.push({ code: state.code, name: state.name });
118+
119+
return groups;
120+
}, []);
121+
}
122+
123+
displayFn(value: any): string {
124+
return value && typeof value === 'object' ? value.name : value;
125+
}
126+
127+
filterStates(val: string) {
128+
return val ? this._filter(this.states, val) : this.states;
129+
}
130+
131+
filterStateGroups(val: string) {
132+
if (val) {
133+
return this.groupedStates
134+
.map(group => ({ letter: group.letter, states: this._filter(group.states, val) }))
135+
.filter(group => group.states.length > 0);
136+
}
137+
138+
return this.groupedStates;
139+
}
140+
141+
private _filter(states: State[], val: string) {
142+
const filterValue = val.toLowerCase();
143+
return states.filter(state => state.name.toLowerCase().startsWith(filterValue));
144+
}
145+
}

0 commit comments

Comments
 (0)