Skip to content

Commit e41d0f3

Browse files
crisbetokara
authored andcommitted
feat(autocomplete): support for md-optgroup (#5604)
Fixes #5581.
1 parent 7f0e58e commit e41d0f3

File tree

9 files changed

+268
-64
lines changed

9 files changed

+268
-64
lines changed

src/demo-app/autocomplete/autocomplete-demo.html

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,37 @@
5151
</md-card-actions>
5252

5353
</md-card>
54+
55+
<md-card>
56+
<div>Option groups (currentGroupedState): {{ currentGroupedState }}</div>
57+
58+
<md-input-container>
59+
<input
60+
mdInput
61+
placeholder="State"
62+
[mdAutocomplete]="groupedAuto"
63+
[(ngModel)]="currentGroupedState"
64+
(ngModelChange)="filteredGroupedStates = filterStateGroups(currentGroupedState)">
65+
</md-input-container>
66+
</md-card>
5467
</div>
68+
69+
<md-autocomplete #reactiveAuto="mdAutocomplete" [displayWith]="displayFn">
70+
<md-option *ngFor="let state of reactiveStates | async" [value]="state">
71+
<span>{{ state.name }}</span>
72+
<span class="demo-secondary-text"> ({{state.code}}) </span>
73+
</md-option>
74+
</md-autocomplete>
75+
76+
<md-autocomplete #tdAuto="mdAutocomplete">
77+
<md-option *ngFor="let state of tdStates" [value]="state.name">
78+
<span>{{ state.name }}</span>
79+
</md-option>
80+
</md-autocomplete>
81+
82+
<md-autocomplete #groupedAuto="mdAutocomplete">
83+
<md-optgroup *ngFor="let group of filteredGroupedStates"
84+
[label]="'States starting with ' + group.letter">
85+
<md-option *ngFor="let state of group.states" [value]="state.name">{{ state.name }}</md-option>
86+
</md-optgroup>
87+
</md-autocomplete>

src/demo-app/autocomplete/autocomplete-demo.scss

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99

1010
.mat-form-field {
1111
margin-top: 16px;
12+
min-width: 200px;
13+
max-width: 100%;
1214
}
1315
}
1416

src/demo-app/autocomplete/autocomplete-demo.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,16 @@ import {FormControl, NgModel} from '@angular/forms';
33
import 'rxjs/add/operator/startWith';
44
import 'rxjs/add/operator/map';
55

6+
export interface State {
7+
code: string;
8+
name: string;
9+
}
10+
11+
export interface StateGroup {
12+
letter: string;
13+
states: State[];
14+
}
15+
616
@Component({
717
moduleId: module.id,
818
selector: 'autocomplete-demo',
@@ -13,6 +23,7 @@ import 'rxjs/add/operator/map';
1323
export class AutocompleteDemo {
1424
stateCtrl: FormControl;
1525
currentState = '';
26+
currentGroupedState = '';
1627
topHeightCtrl = new FormControl(0);
1728

1829
reactiveStates: any;
@@ -22,7 +33,9 @@ export class AutocompleteDemo {
2233

2334
@ViewChild(NgModel) modelDir: NgModel;
2435

25-
states = [
36+
groupedStates: StateGroup[];
37+
filteredGroupedStates: StateGroup[];
38+
states: State[] = [
2639
{code: 'AL', name: 'Alabama'},
2740
{code: 'AK', name: 'Alaska'},
2841
{code: 'AZ', name: 'Arizona'},
@@ -82,18 +95,41 @@ export class AutocompleteDemo {
8295
.startWith(this.stateCtrl.value)
8396
.map(val => this.displayFn(val))
8497
.map(name => this.filterStates(name));
98+
99+
this.filteredGroupedStates = this.groupedStates = this.states.reduce((groups, state) => {
100+
let group = groups.find(g => g.letter === state.name[0]);
101+
102+
if (!group) {
103+
group = { letter: state.name[0], states: [] };
104+
groups.push(group);
105+
}
106+
107+
group.states.push({ code: state.code, name: state.name });
108+
109+
return groups;
110+
}, [] as StateGroup[]);
85111
}
86112

87113
displayFn(value: any): string {
88114
return value && typeof value === 'object' ? value.name : value;
89115
}
90116

91117
filterStates(val: string) {
118+
return val ? this._filter(this.states, val) : this.states;
119+
}
120+
121+
filterStateGroups(val: string) {
92122
if (val) {
93-
const filterValue = val.toLowerCase();
94-
return this.states.filter(state => state.name.toLowerCase().startsWith(filterValue));
123+
return this.groupedStates
124+
.map(group => ({ letter: group.letter, states: this._filter(group.states, val) }))
125+
.filter(group => group.states.length > 0);
95126
}
96127

97-
return this.states;
128+
return this.groupedStates;
129+
}
130+
131+
private _filter(states: State[], val: string) {
132+
const filterValue = val.toLowerCase();
133+
return states.filter(state => state.name.toLowerCase().startsWith(filterValue));
98134
}
99135
}

src/lib/autocomplete/autocomplete-trigger.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -357,8 +357,10 @@ export class MdAutocompleteTrigger implements ControlValueAccessor, OnDestroy {
357357
* not adjusted.
358358
*/
359359
private _scrollToOption(): void {
360-
const optionOffset = this.autocomplete._keyManager.activeItemIndex ?
361-
this.autocomplete._keyManager.activeItemIndex * AUTOCOMPLETE_OPTION_HEIGHT : 0;
360+
const activeOptionIndex = this.autocomplete._keyManager.activeItemIndex || 0;
361+
const labelCount = MdOption.countGroupLabelsBeforeOption(activeOptionIndex,
362+
this.autocomplete.options, this.autocomplete.optionGroups);
363+
const optionOffset = (activeOptionIndex + labelCount) * AUTOCOMPLETE_OPTION_HEIGHT;
362364
const panelTop = this.autocomplete._getScrollTop();
363365

364366
if (optionOffset < panelTop) {

src/lib/autocomplete/autocomplete.md

Lines changed: 42 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
2-
The autocomplete is a normal text input enhanced by a panel of suggested options. You can read more about
3-
autocompletes in the [Material Design spec](https://material.io/guidelines/components/text-fields.html#text-fields-auto-complete-text-field).
1+
The autocomplete is a normal text input enhanced by a panel of suggested options.
2+
You can read more about autocompletes in the [Material Design spec](https://material.io/guidelines/components/text-fields.html#text-fields-auto-complete-text-field).
43

54
### Simple autocomplete
65

7-
Start by adding a regular `mdInput` to the page. Let's assume you're using the `formControl` directive from the
8-
`@angular/forms` module to track the value of the input.
6+
Start by adding a regular `mdInput` to the page. Let's assume you're using the `formControl`
7+
directive from the `@angular/forms` module to track the value of the input.
98

109
*my-comp.html*
1110
```html
@@ -14,10 +13,10 @@ Start by adding a regular `mdInput` to the page. Let's assume you're using the `
1413
</md-form-field>
1514
```
1615

17-
Next, create the autocomplete panel and the options displayed inside it. Each option should be defined by an
18-
`md-option` tag. Set each option's value property to whatever you'd like the value of the text input to be
19-
upon that option's selection.
20-
16+
Next, create the autocomplete panel and the options displayed inside it. Each option should be
17+
defined by an `md-option` tag. Set each option's value property to whatever you'd like the value
18+
of the text input to be upon that option's selection.
19+
2120
*my-comp.html*
2221
```html
2322
<md-autocomplete>
@@ -27,8 +26,9 @@ upon that option's selection.
2726
</md-autocomplete>
2827
```
2928

30-
Now we'll need to link the text input to its panel. We can do this by exporting the autocomplete panel instance into a
31-
local template variable (here we called it "auto"), and binding that variable to the input's `mdAutocomplete` property.
29+
Now we'll need to link the text input to its panel. We can do this by exporting the autocomplete
30+
panel instance into a local template variable (here we called it "auto"), and binding that variable
31+
to the input's `mdAutocomplete` property.
3232

3333
*my-comp.html*
3434
```html
@@ -47,38 +47,51 @@ local template variable (here we called it "auto"), and binding that variable to
4747

4848
### Adding a custom filter
4949

50-
At this point, the autocomplete panel should be toggleable on focus and options should be selectable. But if we want
51-
our options to filter when we type, we need to add a custom filter.
50+
At this point, the autocomplete panel should be toggleable on focus and options should be
51+
selectable. But if we want our options to filter when we type, we need to add a custom filter.
5252

53-
You can filter the options in any way you like based on the text input*. Here we will perform a simple string test on
54-
the option value to see if it matches the input value, starting from the option's first letter. We already have access
55-
to the built-in `valueChanges` observable on the `FormControl`, so we can simply map the text input's values to the
56-
suggested options by passing them through this filter. The resulting observable (`filteredOptions`) can be added to the
53+
You can filter the options in any way you like based on the text input*. Here we will perform a
54+
simple string test on the option value to see if it matches the input value, starting from the
55+
option's first letter. We already have access to the built-in `valueChanges` observable on the
56+
`FormControl`, so we can simply map the text input's values to the suggested options by passing
57+
them through this filter. The resulting observable (`filteredOptions`) can be added to the
5758
template in place of the `options` property using the `async` pipe.
5859

59-
Below we are also priming our value change stream with `null` so that the options are filtered by that value on init
60-
(before there are any value changes).
60+
Below we are also priming our value change stream with `null` so that the options are filtered by
61+
that value on init (before there are any value changes).
6162

62-
*For optimal accessibility, you may want to consider adding text guidance on the page to explain filter criteria.
63-
This is especially helpful for screenreader users if you're using a non-standard filter that doesn't limit matches
64-
to the beginning of the string.
63+
*For optimal accessibility, you may want to consider adding text guidance on the page to explain
64+
filter criteria. This is especially helpful for screenreader users if you're using a non-standard
65+
filter that doesn't limit matches to the beginning of the string.
6566

6667
<!-- example(autocomplete-filter) -->
6768

6869
### Setting separate control and display values
6970

70-
If you want the option's control value (what is saved in the form) to be different than the option's display value
71-
(what is displayed in the actual text field), you'll need to set the `displayWith` property on your autocomplete
72-
element. A common use case for this might be if you want to save your data as an object, but display just one of
73-
the option's string properties.
71+
If you want the option's control value (what is saved in the form) to be different than the option's
72+
display value (what is displayed in the actual text field), you'll need to set the `displayWith`
73+
property on your autocomplete element. A common use case for this might be if you want to save your
74+
data as an object, but display just one of the option's string properties.
7475

75-
To make this work, create a function on your component class that maps the control value to the desired display value.
76-
Then bind it to the autocomplete's `displayWith` property.
76+
To make this work, create a function on your component class that maps the control value to the
77+
desired display value. Then bind it to the autocomplete's `displayWith` property.
7778

7879
<!-- example(autocomplete-display) -->
7980

80-
8181
### Keyboard interaction
8282
- <kbd>DOWN_ARROW</kbd>: Next option becomes active.
8383
- <kbd>UP_ARROW</kbd>: Previous option becomes active.
8484
- <kbd>ENTER</kbd>: Select currently active item.
85+
86+
#### Option groups
87+
`md-option` can be collected into groups using the `md-optgroup` element:
88+
89+
```html
90+
<md-autocomplete #auto="mdAutocomplete">
91+
<md-optgroup *ngFor="let group of filteredGroups | async" [label]="group.name">
92+
<md-option *ngFor="let option of group.options" [value]="option">
93+
{{ option.name }}
94+
</md-option>
95+
</md-optgroup>
96+
</md-autocomplete>
97+
```

src/lib/autocomplete/autocomplete.spec.ts

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ describe('MdAutocomplete', () => {
5555
AutocompleteWithOnPushDelay,
5656
AutocompleteWithNativeInput,
5757
AutocompleteWithoutPanel,
58-
AutocompleteWithFormsAndNonfloatingPlaceholder
58+
AutocompleteWithFormsAndNonfloatingPlaceholder,
59+
AutocompleteWithGroups
5960
],
6061
providers: [
6162
{provide: OverlayContainer, useFactory: () => {
@@ -986,6 +987,79 @@ describe('MdAutocomplete', () => {
986987

987988
});
988989

990+
describe('option groups', () => {
991+
let fixture: ComponentFixture<AutocompleteWithGroups>;
992+
let DOWN_ARROW_EVENT: KeyboardEvent;
993+
let UP_ARROW_EVENT: KeyboardEvent;
994+
let container: HTMLElement;
995+
996+
beforeEach(fakeAsync(() => {
997+
fixture = TestBed.createComponent(AutocompleteWithGroups);
998+
fixture.detectChanges();
999+
1000+
DOWN_ARROW_EVENT = createKeyboardEvent('keydown', DOWN_ARROW);
1001+
UP_ARROW_EVENT = createKeyboardEvent('keydown', UP_ARROW);
1002+
1003+
fixture.componentInstance.trigger.openPanel();
1004+
fixture.detectChanges();
1005+
tick();
1006+
fixture.detectChanges();
1007+
container = document.querySelector('.mat-autocomplete-panel') as HTMLElement;
1008+
}));
1009+
1010+
it('should scroll to active options below the fold', fakeAsync(() => {
1011+
fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
1012+
tick();
1013+
fixture.detectChanges();
1014+
expect(container.scrollTop).toBe(0, 'Expected the panel not to scroll.');
1015+
1016+
// Press the down arrow five times.
1017+
[1, 2, 3, 4, 5].forEach(() => {
1018+
fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
1019+
tick();
1020+
});
1021+
1022+
// <option bottom> - <panel height> + <2x group labels> = 128
1023+
// 288 - 256 + 96 = 128
1024+
expect(container.scrollTop)
1025+
.toBe(128, 'Expected panel to reveal the sixth option.');
1026+
}));
1027+
1028+
it('should scroll to active options on UP arrow', fakeAsync(() => {
1029+
fixture.componentInstance.trigger._handleKeydown(UP_ARROW_EVENT);
1030+
tick();
1031+
fixture.detectChanges();
1032+
1033+
// <option bottom> - <panel height> + <3x group label> = 464
1034+
// 576 - 256 + 144 = 464
1035+
expect(container.scrollTop).toBe(464, 'Expected panel to reveal last option.');
1036+
}));
1037+
1038+
it('should scroll to active options that are above the panel', fakeAsync(() => {
1039+
fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
1040+
tick();
1041+
fixture.detectChanges();
1042+
expect(container.scrollTop).toBe(0, 'Expected panel not to scroll.');
1043+
1044+
// These down arrows will set the 7th option active, below the fold.
1045+
[1, 2, 3, 4, 5, 6].forEach(() => {
1046+
fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
1047+
tick();
1048+
});
1049+
1050+
// These up arrows will set the 2nd option active
1051+
[5, 4, 3, 2, 1].forEach(() => {
1052+
fixture.componentInstance.trigger._handleKeydown(UP_ARROW_EVENT);
1053+
tick();
1054+
});
1055+
1056+
// Expect to show the top of the 2nd option at the top of the panel.
1057+
// It is offset by 48, because there's a group label above it.
1058+
expect(container.scrollTop)
1059+
.toBe(96, 'Expected panel to scroll up when option is above panel.');
1060+
}));
1061+
});
1062+
9891063
describe('aria', () => {
9901064
let fixture: ComponentFixture<SimpleAutocomplete>;
9911065
let input: HTMLInputElement;
@@ -1706,3 +1780,38 @@ class AutocompleteWithoutPanel {
17061780
class AutocompleteWithFormsAndNonfloatingPlaceholder {
17071781
formControl = new FormControl('California');
17081782
}
1783+
1784+
1785+
@Component({
1786+
template: `
1787+
<md-input-container>
1788+
<input mdInput placeholder="State" [mdAutocomplete]="auto" [(ngModel)]="selectedState">
1789+
</md-input-container>
1790+
1791+
<md-autocomplete #auto="mdAutocomplete">
1792+
<md-optgroup *ngFor="let group of stateGroups" [label]="group.label">
1793+
<md-option *ngFor="let state of group.states" [value]="state">
1794+
<span>{{ state }}</span>
1795+
</md-option>
1796+
</md-optgroup>
1797+
</md-autocomplete>
1798+
`
1799+
})
1800+
class AutocompleteWithGroups {
1801+
@ViewChild(MdAutocompleteTrigger) trigger: MdAutocompleteTrigger;
1802+
selectedState: string;
1803+
stateGroups = [
1804+
{
1805+
title: 'One',
1806+
states: ['Alabama', 'California', 'Florida', 'Oregon']
1807+
},
1808+
{
1809+
title: 'Two',
1810+
states: ['Kansas', 'Massachusetts', 'New York', 'Pennsylvania']
1811+
},
1812+
{
1813+
title: 'Three',
1814+
states: ['Tennessee', 'Virginia', 'Wyoming', 'Alaska']
1815+
}
1816+
];
1817+
}

0 commit comments

Comments
 (0)