From e41d0f3e9b6251bd585945e5953b330302a236f1 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Wed, 23 Aug 2017 01:01:00 +0200 Subject: [PATCH] feat(autocomplete): support for md-optgroup (#5604) Fixes #5581. --- .../autocomplete/autocomplete-demo.html | 33 ++++++ .../autocomplete/autocomplete-demo.scss | 2 + .../autocomplete/autocomplete-demo.ts | 44 ++++++- src/lib/autocomplete/autocomplete-trigger.ts | 6 +- src/lib/autocomplete/autocomplete.md | 71 ++++++----- src/lib/autocomplete/autocomplete.spec.ts | 111 +++++++++++++++++- src/lib/autocomplete/autocomplete.ts | 7 +- src/lib/core/option/option.ts | 27 +++++ src/lib/select/select.ts | 31 +---- 9 files changed, 268 insertions(+), 64 deletions(-) diff --git a/src/demo-app/autocomplete/autocomplete-demo.html b/src/demo-app/autocomplete/autocomplete-demo.html index 97ae6761a083..5ca27f2a5d0b 100644 --- a/src/demo-app/autocomplete/autocomplete-demo.html +++ b/src/demo-app/autocomplete/autocomplete-demo.html @@ -51,4 +51,37 @@ + + +
Option groups (currentGroupedState): {{ currentGroupedState }}
+ + + + +
+ + + + {{ state.name }} + ({{state.code}}) + + + + + + {{ state.name }} + + + + + + {{ state.name }} + + diff --git a/src/demo-app/autocomplete/autocomplete-demo.scss b/src/demo-app/autocomplete/autocomplete-demo.scss index f2018cc19e80..a74ecd15128f 100644 --- a/src/demo-app/autocomplete/autocomplete-demo.scss +++ b/src/demo-app/autocomplete/autocomplete-demo.scss @@ -9,6 +9,8 @@ .mat-form-field { margin-top: 16px; + min-width: 200px; + max-width: 100%; } } diff --git a/src/demo-app/autocomplete/autocomplete-demo.ts b/src/demo-app/autocomplete/autocomplete-demo.ts index 96c25f70f866..a138a007df82 100644 --- a/src/demo-app/autocomplete/autocomplete-demo.ts +++ b/src/demo-app/autocomplete/autocomplete-demo.ts @@ -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)); } } diff --git a/src/lib/autocomplete/autocomplete-trigger.ts b/src/lib/autocomplete/autocomplete-trigger.ts index 5d90c9bb4406..207701b32912 100644 --- a/src/lib/autocomplete/autocomplete-trigger.ts +++ b/src/lib/autocomplete/autocomplete-trigger.ts @@ -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) { diff --git a/src/lib/autocomplete/autocomplete.md b/src/lib/autocomplete/autocomplete.md index cb5a4f0c0d3b..b3e7e4ece279 100644 --- a/src/lib/autocomplete/autocomplete.md +++ b/src/lib/autocomplete/autocomplete.md @@ -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 ` ``` -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 @@ -27,8 +26,9 @@ upon that option's selection. ``` -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. ### 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. - ### Keyboard interaction - DOWN_ARROW: Next option becomes active. - UP_ARROW: Previous option becomes active. - ENTER: Select currently active item. + +#### Option groups +`md-option` can be collected into groups using the `md-optgroup` element: + +```html + + + + {{ option.name }} + + + +``` diff --git a/src/lib/autocomplete/autocomplete.spec.ts b/src/lib/autocomplete/autocomplete.spec.ts index a20d0ab3d9ec..f9bf7961fb22 100644 --- a/src/lib/autocomplete/autocomplete.spec.ts +++ b/src/lib/autocomplete/autocomplete.spec.ts @@ -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; + 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(); + }); + + //