feat(autocomplete): support for md-optgroup (#5604)

Fixes #5581.
This commit is contained in:
Kristiyan Kostadinov
2017-08-23 01:01:00 +02:00
committed by Kara
parent 7f0e58e432
commit e41d0f3e9b
9 changed files with 268 additions and 64 deletions
@@ -51,4 +51,37 @@ Space above cards: <input type="number" [formControl]="topHeightCtrl">
</md-card-actions>
</md-card>
<md-card>
<div>Option groups (currentGroupedState): {{ currentGroupedState }}</div>
<md-input-container>
<input
mdInput
placeholder="State"
[mdAutocomplete]="groupedAuto"
[(ngModel)]="currentGroupedState"
(ngModelChange)="filteredGroupedStates = filterStateGroups(currentGroupedState)">
</md-input-container>
</md-card>
</div>
<md-autocomplete #reactiveAuto="mdAutocomplete" [displayWith]="displayFn">
<md-option *ngFor="let state of reactiveStates | async" [value]="state">
<span>{{ state.name }}</span>
<span class="demo-secondary-text"> ({{state.code}}) </span>
</md-option>
</md-autocomplete>
<md-autocomplete #tdAuto="mdAutocomplete">
<md-option *ngFor="let state of tdStates" [value]="state.name">
<span>{{ state.name }}</span>
</md-option>
</md-autocomplete>
<md-autocomplete #groupedAuto="mdAutocomplete">
<md-optgroup *ngFor="let group of filteredGroupedStates"
[label]="'States starting with ' + group.letter">
<md-option *ngFor="let state of group.states" [value]="state.name">{{ state.name }}</md-option>
</md-optgroup>
</md-autocomplete>
@@ -9,6 +9,8 @@
.mat-form-field {
margin-top: 16px;
min-width: 200px;
max-width: 100%;
}
}
+40 -4
View File
@@ -3,6 +3,16 @@ import {FormControl, NgModel} from '@angular/forms';
import 'rxjs/add/operator/startWith';
import 'rxjs/add/operator/map';
export interface State {
code: string;
name: string;
}
export interface StateGroup {
letter: string;
states: State[];
}
@Component({
moduleId: module.id,
selector: 'autocomplete-demo',
@@ -13,6 +23,7 @@ import 'rxjs/add/operator/map';
export class AutocompleteDemo {
stateCtrl: FormControl;
currentState = '';
currentGroupedState = '';
topHeightCtrl = new FormControl(0);
reactiveStates: any;
@@ -22,7 +33,9 @@ export class AutocompleteDemo {
@ViewChild(NgModel) modelDir: NgModel;
states = [
groupedStates: StateGroup[];
filteredGroupedStates: StateGroup[];
states: State[] = [
{code: 'AL', name: 'Alabama'},
{code: 'AK', name: 'Alaska'},
{code: 'AZ', name: 'Arizona'},
@@ -82,6 +95,19 @@ export class AutocompleteDemo {
.startWith(this.stateCtrl.value)
.map(val => this.displayFn(val))
.map(name => this.filterStates(name));
this.filteredGroupedStates = this.groupedStates = this.states.reduce((groups, state) => {
let group = groups.find(g => g.letter === state.name[0]);
if (!group) {
group = { letter: state.name[0], states: [] };
groups.push(group);
}
group.states.push({ code: state.code, name: state.name });
return groups;
}, [] as StateGroup[]);
}
displayFn(value: any): string {
@@ -89,11 +115,21 @@ export class AutocompleteDemo {
}
filterStates(val: string) {
return val ? this._filter(this.states, val) : this.states;
}
filterStateGroups(val: string) {
if (val) {
const filterValue = val.toLowerCase();
return this.states.filter(state => state.name.toLowerCase().startsWith(filterValue));
return this.groupedStates
.map(group => ({ letter: group.letter, states: this._filter(group.states, val) }))
.filter(group => group.states.length > 0);
}
return this.states;
return this.groupedStates;
}
private _filter(states: State[], val: string) {
const filterValue = val.toLowerCase();
return states.filter(state => state.name.toLowerCase().startsWith(filterValue));
}
}
+4 -2
View File
@@ -357,8 +357,10 @@ export class MdAutocompleteTrigger implements ControlValueAccessor, OnDestroy {
* not adjusted.
*/
private _scrollToOption(): void {
const optionOffset = this.autocomplete._keyManager.activeItemIndex ?
this.autocomplete._keyManager.activeItemIndex * AUTOCOMPLETE_OPTION_HEIGHT : 0;
const activeOptionIndex = this.autocomplete._keyManager.activeItemIndex || 0;
const labelCount = MdOption.countGroupLabelsBeforeOption(activeOptionIndex,
this.autocomplete.options, this.autocomplete.optionGroups);
const optionOffset = (activeOptionIndex + labelCount) * AUTOCOMPLETE_OPTION_HEIGHT;
const panelTop = this.autocomplete._getScrollTop();
if (optionOffset < panelTop) {
+42 -29
View File
@@ -1,11 +1,10 @@
The autocomplete is a normal text input enhanced by a panel of suggested options. 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).
The autocomplete is a normal text input enhanced by a panel of suggested options.
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).
### Simple autocomplete
Start by adding a regular `mdInput` to the page. Let's assume you're using the `formControl` directive from the
`@angular/forms` module to track the value of the input.
Start by adding a regular `mdInput` to the page. Let's assume you're using the `formControl`
directive from the `@angular/forms` module to track the value of the input.
*my-comp.html*
```html
@@ -14,10 +13,10 @@ Start by adding a regular `mdInput` to the page. Let's assume you're using the `
</md-form-field>
```
Next, create the autocomplete panel and the options displayed inside it. Each option should be defined by an
`md-option` tag. Set each option's value property to whatever you'd like the value of the text input to be
upon that option's selection.
Next, create the autocomplete panel and the options displayed inside it. Each option should be
defined by an `md-option` tag. Set each option's value property to whatever you'd like the value
of the text input to be upon that option's selection.
*my-comp.html*
```html
<md-autocomplete>
@@ -27,8 +26,9 @@ upon that option's selection.
</md-autocomplete>
```
Now we'll need to link the text input to its panel. We can do this by exporting the autocomplete panel instance into a
local template variable (here we called it "auto"), and binding that variable to the input's `mdAutocomplete` property.
Now we'll need to link the text input to its panel. We can do this by exporting the autocomplete
panel instance into a local template variable (here we called it "auto"), and binding that variable
to the input's `mdAutocomplete` property.
*my-comp.html*
```html
@@ -47,38 +47,51 @@ local template variable (here we called it "auto"), and binding that variable to
### Adding a custom filter
At this point, the autocomplete panel should be toggleable on focus and options should be selectable. But if we want
our options to filter when we type, we need to add a custom filter.
At this point, the autocomplete panel should be toggleable on focus and options should be
selectable. But if we want our options to filter when we type, we need to add a custom filter.
You can filter the options in any way you like based on the text input*. Here we will perform a simple string test on
the option value to see if it matches the input value, starting from the option's first letter. We already have access
to the built-in `valueChanges` observable on the `FormControl`, so we can simply map the text input's values to the
suggested options by passing them through this filter. The resulting observable (`filteredOptions`) can be added to the
You can filter the options in any way you like based on the text input*. Here we will perform a
simple string test on the option value to see if it matches the input value, starting from the
option's first letter. We already have access to the built-in `valueChanges` observable on the
`FormControl`, so we can simply map the text input's values to the suggested options by passing
them through this filter. The resulting observable (`filteredOptions`) can be added to the
template in place of the `options` property using the `async` pipe.
Below we are also priming our value change stream with `null` so that the options are filtered by that value on init
(before there are any value changes).
Below we are also priming our value change stream with `null` so that the options are filtered by
that value on init (before there are any value changes).
*For optimal accessibility, you may want to consider adding text guidance on the page to explain filter criteria.
This is especially helpful for screenreader users if you're using a non-standard filter that doesn't limit matches
to the beginning of the string.
*For optimal accessibility, you may want to consider adding text guidance on the page to explain
filter criteria. This is especially helpful for screenreader users if you're using a non-standard
filter that doesn't limit matches to the beginning of the string.
<!-- example(autocomplete-filter) -->
### Setting separate control and display values
If you want the option's control value (what is saved in the form) to be different than the option's display value
(what is displayed in the actual text field), you'll need to set the `displayWith` property on your autocomplete
element. A common use case for this might be if you want to save your data as an object, but display just one of
the option's string properties.
If you want the option's control value (what is saved in the form) to be different than the option's
display value (what is displayed in the actual text field), you'll need to set the `displayWith`
property on your autocomplete element. A common use case for this might be if you want to save your
data as an object, but display just one of the option's string properties.
To make this work, create a function on your component class that maps the control value to the desired display value.
Then bind it to the autocomplete's `displayWith` property.
To make this work, create a function on your component class that maps the control value to the
desired display value. Then bind it to the autocomplete's `displayWith` property.
<!-- example(autocomplete-display) -->
### Keyboard interaction
- <kbd>DOWN_ARROW</kbd>: Next option becomes active.
- <kbd>UP_ARROW</kbd>: Previous option becomes active.
- <kbd>ENTER</kbd>: Select currently active item.
#### Option groups
`md-option` can be collected into groups using the `md-optgroup` element:
```html
<md-autocomplete #auto="mdAutocomplete">
<md-optgroup *ngFor="let group of filteredGroups | async" [label]="group.name">
<md-option *ngFor="let option of group.options" [value]="option">
{{ option.name }}
</md-option>
</md-optgroup>
</md-autocomplete>
```
+110 -1
View File
@@ -55,7 +55,8 @@ describe('MdAutocomplete', () => {
AutocompleteWithOnPushDelay,
AutocompleteWithNativeInput,
AutocompleteWithoutPanel,
AutocompleteWithFormsAndNonfloatingPlaceholder
AutocompleteWithFormsAndNonfloatingPlaceholder,
AutocompleteWithGroups
],
providers: [
{provide: OverlayContainer, useFactory: () => {
@@ -986,6 +987,79 @@ describe('MdAutocomplete', () => {
});
describe('option groups', () => {
let fixture: ComponentFixture<AutocompleteWithGroups>;
let DOWN_ARROW_EVENT: KeyboardEvent;
let UP_ARROW_EVENT: KeyboardEvent;
let container: HTMLElement;
beforeEach(fakeAsync(() => {
fixture = TestBed.createComponent(AutocompleteWithGroups);
fixture.detectChanges();
DOWN_ARROW_EVENT = createKeyboardEvent('keydown', DOWN_ARROW);
UP_ARROW_EVENT = createKeyboardEvent('keydown', UP_ARROW);
fixture.componentInstance.trigger.openPanel();
fixture.detectChanges();
tick();
fixture.detectChanges();
container = document.querySelector('.mat-autocomplete-panel') as HTMLElement;
}));
it('should scroll to active options below the fold', fakeAsync(() => {
fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
tick();
fixture.detectChanges();
expect(container.scrollTop).toBe(0, 'Expected the panel not to scroll.');
// Press the down arrow five times.
[1, 2, 3, 4, 5].forEach(() => {
fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
tick();
});
// <option bottom> - <panel height> + <2x group labels> = 128
// 288 - 256 + 96 = 128
expect(container.scrollTop)
.toBe(128, 'Expected panel to reveal the sixth option.');
}));
it('should scroll to active options on UP arrow', fakeAsync(() => {
fixture.componentInstance.trigger._handleKeydown(UP_ARROW_EVENT);
tick();
fixture.detectChanges();
// <option bottom> - <panel height> + <3x group label> = 464
// 576 - 256 + 144 = 464
expect(container.scrollTop).toBe(464, 'Expected panel to reveal last option.');
}));
it('should scroll to active options that are above the panel', fakeAsync(() => {
fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
tick();
fixture.detectChanges();
expect(container.scrollTop).toBe(0, 'Expected panel not to scroll.');
// These down arrows will set the 7th option active, below the fold.
[1, 2, 3, 4, 5, 6].forEach(() => {
fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
tick();
});
// These up arrows will set the 2nd option active
[5, 4, 3, 2, 1].forEach(() => {
fixture.componentInstance.trigger._handleKeydown(UP_ARROW_EVENT);
tick();
});
// Expect to show the top of the 2nd option at the top of the panel.
// It is offset by 48, because there's a group label above it.
expect(container.scrollTop)
.toBe(96, 'Expected panel to scroll up when option is above panel.');
}));
});
describe('aria', () => {
let fixture: ComponentFixture<SimpleAutocomplete>;
let input: HTMLInputElement;
@@ -1706,3 +1780,38 @@ class AutocompleteWithoutPanel {
class AutocompleteWithFormsAndNonfloatingPlaceholder {
formControl = new FormControl('California');
}
@Component({
template: `
<md-input-container>
<input mdInput placeholder="State" [mdAutocomplete]="auto" [(ngModel)]="selectedState">
</md-input-container>
<md-autocomplete #auto="mdAutocomplete">
<md-optgroup *ngFor="let group of stateGroups" [label]="group.label">
<md-option *ngFor="let state of group.states" [value]="state">
<span>{{ state }}</span>
</md-option>
</md-optgroup>
</md-autocomplete>
`
})
class AutocompleteWithGroups {
@ViewChild(MdAutocompleteTrigger) trigger: MdAutocompleteTrigger;
selectedState: string;
stateGroups = [
{
title: 'One',
states: ['Alabama', 'California', 'Florida', 'Oregon']
},
{
title: 'Two',
states: ['Kansas', 'Massachusetts', 'New York', 'Pennsylvania']
},
{
title: 'Three',
states: ['Tennessee', 'Virginia', 'Wyoming', 'Alaska']
}
];
}
+5 -2
View File
@@ -19,7 +19,7 @@ import {
ChangeDetectorRef,
ChangeDetectionStrategy,
} from '@angular/core';
import {MdOption} from '../core';
import {MdOption, MdOptgroup} from '../core';
import {ActiveDescendantKeyManager} from '@angular/cdk/a11y';
/**
@@ -55,7 +55,10 @@ export class MdAutocomplete implements AfterContentInit {
@ViewChild('panel') panel: ElementRef;
/** @docs-private */
@ContentChildren(MdOption) options: QueryList<MdOption>;
@ContentChildren(MdOption, { descendants: true }) options: QueryList<MdOption>;
/** @docs-private */
@ContentChildren(MdOptgroup) optionGroups: QueryList<MdOptgroup>;
/** Function that maps an option's control value to its display value in the trigger. */
@Input() displayWith: ((value: any) => string) | null = null;
+27
View File
@@ -17,6 +17,7 @@ import {
Optional,
ChangeDetectionStrategy,
ChangeDetectorRef,
QueryList,
} from '@angular/core';
import {ENTER, SPACE} from '../keyboard/keycodes';
import {coerceBooleanProperty} from '@angular/cdk/coercion';
@@ -216,4 +217,30 @@ export class MdOption {
this.onSelectionChange.emit(new MdOptionSelectionChange(this, isUserInput));
}
/**
* Counts the amount of option group labels that precede the specified option.
* @param optionIndex Index of the option at which to start counting.
* @param options Flat list of all of the options.
* @param optionGroups Flat list of all of the option groups.
*/
static countGroupLabelsBeforeOption(optionIndex: number, options: QueryList<MdOption>,
optionGroups: QueryList<MdOptgroup>): number {
if (optionGroups.length) {
let optionsArray = options.toArray();
let groups = optionGroups.toArray();
let groupCounter = 0;
for (let i = 0; i < optionIndex + 1; i++) {
if (optionsArray[i].group && optionsArray[i].group === groups[groupCounter]) {
groupCounter++;
}
}
return groupCounter;
}
return 0;
}
}
+5 -26
View File
@@ -939,7 +939,8 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
if (this._hasValue()) {
let selectedOptionOffset = this._getOptionIndex(this._selectionModel.selected[0])!;
selectedOptionOffset += this._getLabelCountBeforeOption(selectedOptionOffset);
selectedOptionOffset += MdOption.countGroupLabelsBeforeOption(selectedOptionOffset,
this.options, this.optionGroups);
// We must maintain a scroll buffer so the selected option will be scrolled to the
// center of the overlay panel rather than the top.
@@ -951,8 +952,10 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
// we must only adjust for the height difference between the option element
// and the trigger element, then multiply it by -1 to ensure the panel moves
// in the correct direction up the page.
let groupLabels = MdOption.countGroupLabelsBeforeOption(0, this.options, this.optionGroups);
this._offsetY = (SELECT_ITEM_HEIGHT - SELECT_TRIGGER_HEIGHT) / 2 * -1 -
(this._getLabelCountBeforeOption(0) * SELECT_ITEM_HEIGHT);
(groupLabels * SELECT_ITEM_HEIGHT);
}
this._checkOverlayWithinViewport(maxScroll);
@@ -1197,30 +1200,6 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
private _getItemCount(): number {
return this.options.length + this.optionGroups.length;
}
/**
* Calculates the amount of option group labels that precede the specified option.
* Useful when positioning the panel, because the labels will offset the index of the
* currently-selected option.
*/
private _getLabelCountBeforeOption(optionIndex: number): number {
if (this.optionGroups.length) {
let options = this.options.toArray();
let groups = this.optionGroups.toArray();
let groupCounter = 0;
for (let i = 0; i < optionIndex + 1; i++) {
if (options[i].group && options[i].group === groups[groupCounter]) {
groupCounter++;
}
}
return groupCounter;
}
return 0;
}
}
/** Clamps a value n between min and max values. */