diff --git a/src/demo-app/select/select-demo.html b/src/demo-app/select/select-demo.html index 211cb48ec00..67390a5987c 100644 --- a/src/demo-app/select/select-demo.html +++ b/src/demo-app/select/select-demo.html @@ -1 +1,5 @@ - \ No newline at end of file +
+ + {{ food.viewValue }} + +
diff --git a/src/demo-app/select/select-demo.scss b/src/demo-app/select/select-demo.scss index e69de29bb2d..9c6784bed05 100644 --- a/src/demo-app/select/select-demo.scss +++ b/src/demo-app/select/select-demo.scss @@ -0,0 +1,2 @@ +.demo-select { +} \ No newline at end of file diff --git a/src/demo-app/select/select-demo.ts b/src/demo-app/select/select-demo.ts index 9fd1b534d89..d8d9ec1615b 100644 --- a/src/demo-app/select/select-demo.ts +++ b/src/demo-app/select/select-demo.ts @@ -8,5 +8,9 @@ import {Component} from '@angular/core'; styleUrls: ['select-demo.css'], }) export class SelectDemo { - + foods = [ + {value: 'steak', viewValue: 'Steak'}, + {value: 'pizza', viewValue: 'Pizza'}, + {value: 'tacos', viewValue: 'Tacos'} + ]; } diff --git a/src/lib/core/a11y/list-key-manager.ts b/src/lib/core/a11y/list-key-manager.ts index 3dd1bfd6a03..98bc878c06f 100644 --- a/src/lib/core/a11y/list-key-manager.ts +++ b/src/lib/core/a11y/list-key-manager.ts @@ -30,6 +30,10 @@ export class ListKeyManager { return this._tabOut.asObservable(); } + get focusedItemIndex(): number { + return this._focusedItemIndex; + } + set focusedItemIndex(value: number) { this._focusedItemIndex = value; } diff --git a/src/lib/core/compatibility/style-compatibility.ts b/src/lib/core/compatibility/style-compatibility.ts index 4db50e4f2a5..cd19d6a8db3 100644 --- a/src/lib/core/compatibility/style-compatibility.ts +++ b/src/lib/core/compatibility/style-compatibility.ts @@ -26,6 +26,7 @@ export const ELEMENTS_SELECTOR = ` md-list-item, md-menu, md-nav-list, + md-option, md-placeholder, md-progress-bar, md-progress-circle, diff --git a/src/lib/select/README.md b/src/lib/select/README.md new file mode 100644 index 00000000000..6d192d19b45 --- /dev/null +++ b/src/lib/select/README.md @@ -0,0 +1,3 @@ +## Work in progress! + +The select is still a work in progress, so most features have not been implemented. Not ready for use! \ No newline at end of file diff --git a/src/lib/select/_select-theme.scss b/src/lib/select/_select-theme.scss index 78ac7a3f8cd..cdb52334dab 100644 --- a/src/lib/select/_select-theme.scss +++ b/src/lib/select/_select-theme.scss @@ -2,5 +2,50 @@ @import '../core/theming/theming'; @mixin md-select-theme($theme) { + $foreground: map-get($theme, foreground); + $background: map-get($theme, background); + $primary: map-get($theme, primary); + .md-select-trigger { + color: md-color($foreground, hint-text); + border-bottom: 1px solid md-color($foreground, divider); + + md-select:focus & { + border-bottom: 1px solid md-color($primary); + } + } + + .md-select-placeholder { + md-select:focus & { + color: md-color($primary); + } + } + + .md-select-arrow { + color: md-color($foreground, hint-text); + + md-select:focus & { + color: md-color($primary); + } + } + + .md-select-content { + background: md-color($background, card); + } + + .md-select-value { + color: md-color($foreground, text); + } + + md-option { + &:hover, &:focus { + background: md-color($background, hover); + } + + &.md-selected { + background: md-color($background, hover); + color: md-color($primary); + } + + } } diff --git a/src/lib/select/index.ts b/src/lib/select/index.ts index da05b1ee80f..286da03f8fd 100644 --- a/src/lib/select/index.ts +++ b/src/lib/select/index.ts @@ -1,10 +1,14 @@ import {NgModule, ModuleWithProviders} from '@angular/core'; +import {CommonModule} from '@angular/common'; import {MdSelect} from './select'; import {MdOption} from './option'; +import {OverlayModule} from '../core/overlay/overlay-directives'; +import {MdRippleModule} from '../core/ripple/ripple'; +import {OVERLAY_PROVIDERS} from '../core/overlay/overlay'; export * from './select'; @NgModule({ - imports: [], + imports: [CommonModule, OverlayModule, MdRippleModule], exports: [MdSelect, MdOption], declarations: [MdSelect, MdOption], }) @@ -12,7 +16,7 @@ export class MdSelectModule { static forRoot(): ModuleWithProviders { return { ngModule: MdSelectModule, - providers: [] + providers: [OVERLAY_PROVIDERS] }; } } diff --git a/src/lib/select/option.html b/src/lib/select/option.html new file mode 100644 index 00000000000..4eb16379239 --- /dev/null +++ b/src/lib/select/option.html @@ -0,0 +1,3 @@ + +
\ No newline at end of file diff --git a/src/lib/select/option.ts b/src/lib/select/option.ts index ac716c04005..791e683a50e 100644 --- a/src/lib/select/option.ts +++ b/src/lib/select/option.ts @@ -1,10 +1,75 @@ -import {Component, ViewEncapsulation} from '@angular/core'; +import { + Component, + ElementRef, + EventEmitter, + Output, + Renderer, + ViewEncapsulation +} from '@angular/core'; +import {ENTER, SPACE} from '../core/keyboard/keycodes'; @Component({ moduleId: module.id, selector: 'md-option', - template: ``, + host: { + 'role': 'option', + 'tabindex': '0', + '[class.md-selected]': 'selected', + '[attr.aria-selected]': 'selected.toString()', + '(click)': 'select()', + '(keydown)': '_handleKeydown($event)' + }, + templateUrl: 'option.html', styleUrls: ['select.css'], encapsulation: ViewEncapsulation.None }) -export class MdOption {} +export class MdOption { + private _selected = false; + + /** Event emitted when the option is selected. */ + @Output() onSelect = new EventEmitter(); + + constructor(private _element: ElementRef, private _renderer: Renderer) {} + + /** Whether or not the option is currently selected. */ + get selected(): boolean { + return this._selected; + } + + /** + * The displayed value of the option. It is necessary to show the selected option in the + * select's trigger. + * TODO(kara): Add input property alternative for node envs. + */ + get viewValue(): string { + return this._getHostElement().textContent.trim(); + } + + /** Selects the option. */ + select(): void { + this._selected = true; + this.onSelect.emit(); + } + + /** Deselects the option. */ + deselect(): void { + this._selected = false; + } + + /** Sets focus onto this option. */ + focus(): void { + this._renderer.invokeElementMethod(this._getHostElement(), 'focus'); + } + + /** Ensures the option is selected when activated from the keyboard. */ + _handleKeydown(event: KeyboardEvent): void { + if (event.keyCode === ENTER || event.keyCode === SPACE) { + this.select(); + } + } + + _getHostElement(): HTMLElement { + return this._element.nativeElement; + } + +} diff --git a/src/lib/select/select-animations.ts b/src/lib/select/select-animations.ts new file mode 100644 index 00000000000..e9b1d33c487 --- /dev/null +++ b/src/lib/select/select-animations.ts @@ -0,0 +1,79 @@ +import { + animate, + AnimationEntryMetadata, + state, + style, + transition, + trigger, +} from '@angular/core'; + +/** + * The following are all the animations for the md-select component, with each + * const containing the metadata for one animation. + * + * The values below match the implementation of the Material 1 md-select animation. + */ + +/** + * This animation shrinks the placeholder text to 75% of its normal size and translates + * it to either the top left corner (ltr) or top right corner (rtl) of the trigger, + * depending on the text direction of the application. + */ +export const transformPlaceholder: AnimationEntryMetadata = trigger('transformPlaceholder', [ + state('normal', style({ + transform: `translate3d(0, 0, 0) scale(1)` + })), + state('floating-ltr', style({ + transform: `translate3d(-2px, -22px, 0) scale(0.75)` + })), + state('floating-rtl', style({ + transform: `translate3d(2px, -22px, 0) scale(0.75)` + })), + transition('* => *', animate(`400ms cubic-bezier(0.25, 0.8, 0.25, 1)`)) +]); + +/** + * This animation transforms the select's overlay panel on and off the page. + * + * When the panel is attached to the DOM, it expands its width 32px, scales it up to + * 100% on the Y axis, fades in its border, and translates slightly up and to the + * side to ensure the option text correctly overlaps the trigger text. + * + * When the panel is removed from the DOM, it simply fades out linearly. + */ +export const transformPanel: AnimationEntryMetadata = trigger('transformPanel', [ + state('showing-ltr', style({ + opacity: 1, + width: 'calc(100% + 32px)', + transform: `translate3d(-16px, -9px, 0) scaleY(1)` + })), + state('showing-rtl', style({ + opacity: 1, + width: 'calc(100% + 32px)', + transform: `translate3d(16px, -9px, 0) scaleY(1)` + })), + transition('void => *', [ + style({ + opacity: 0, + width: '100%', + transform: `translate3d(0, 0, 0) scaleY(0)` + }), + animate(`150ms cubic-bezier(0.55, 0, 0.55, 0.2)`) + ]), + transition('* => void', [ + animate('250ms linear', style({opacity: 0})) + ]) +]); + +/** + * This animation fades in the background color and text content of the + * select's options. It is time delayed to occur 100ms after the overlay + * panel has transformed in. + */ +export const fadeInContent: AnimationEntryMetadata = trigger('fadeInContent', [ + state('showing', style({opacity: 1})), + transition('void => showing', [ + style({opacity: 0}), + animate(`150ms 100ms cubic-bezier(0.55, 0, 0.55, 0.2)`) + ]) +]); diff --git a/src/lib/select/select.html b/src/lib/select/select.html index 3c3f1190566..a3b7074dd42 100644 --- a/src/lib/select/select.html +++ b/src/lib/select/select.html @@ -1 +1,15 @@ -I'm a select! \ No newline at end of file +
+ {{ placeholder }} + {{ selected?.viewValue }} + +
+ + diff --git a/src/lib/select/select.scss b/src/lib/select/select.scss index e69de29bb2d..243f8e4e29e 100644 --- a/src/lib/select/select.scss +++ b/src/lib/select/select.scss @@ -0,0 +1,62 @@ +@import '../core/style/menu-common'; + +$md-select-trigger-height: 30px !default; +$md-select-trigger-min-width: 112px !default; +$md-select-arrow-size: 5px !default; + +md-select { + display: inline-block; + outline: none; +} + +.md-select-trigger { + display: flex; + justify-content: space-between; + align-items: center; + height: $md-select-trigger-height; + min-width: $md-select-trigger-min-width; + cursor: pointer; +} + +.md-select-placeholder { + padding: 0 2px; + transform-origin: left top; + + [dir='rtl'] & { + transform-origin: right top; + } +} + +.md-select-value { + position: absolute; +} + +.md-select-arrow { + width: 0; + height: 0; + border-left: $md-select-arrow-size solid transparent; + border-right: $md-select-arrow-size solid transparent; + border-top: $md-select-arrow-size solid; +} + +.md-select-panel { + @include md-menu-base(); + padding-top: 0; + padding-bottom: 0; + transform-origin: top; +} + +md-option { + @include md-menu-item-base(); + position: relative; + cursor: pointer; + outline: none; +} + +.md-option-ripple { + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; +} \ No newline at end of file diff --git a/src/lib/select/select.spec.ts b/src/lib/select/select.spec.ts index 986b33154f4..fb2c5c6e67e 100644 --- a/src/lib/select/select.spec.ts +++ b/src/lib/select/select.spec.ts @@ -1,23 +1,365 @@ -import {TestBed, async} from '@angular/core/testing'; -import {Component} from '@angular/core'; +import {TestBed, async, ComponentFixture} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; +import {Component, DebugElement, QueryList, ViewChild, ViewChildren} from '@angular/core'; import {MdSelectModule} from './index'; - +import {OverlayContainer} from '../core/overlay/overlay-container'; +import {MdSelect} from './select'; +import {MdOption} from './option'; +import {Dir} from '../core/rtl/dir'; describe('MdSelect', () => { + let overlayContainerElement: HTMLElement; + let dir: {value: string}; + beforeEach(async(() => { TestBed.configureTestingModule({ imports: [MdSelectModule.forRoot()], - declarations: [TestSelect], + declarations: [BasicSelect], + providers: [ + {provide: OverlayContainer, useFactory: () => { + overlayContainerElement = document.createElement('div'); + return {getContainerElement: () => overlayContainerElement}; + }}, + {provide: Dir, useFactory: () => { + return dir = { value: 'ltr' }; + }} + ] }); TestBed.compileComponents(); })); - it('should test', () => { - // let fixture = TestBed.createComponent(TestSelect); - expect(true).toBeTruthy(); + describe('overlay panel', () => { + let fixture: ComponentFixture; + let trigger: HTMLElement; + + beforeEach(() => { + fixture = TestBed.createComponent(BasicSelect); + fixture.detectChanges(); + + trigger = fixture.debugElement.query(By.css('.md-select-trigger')).nativeElement; + }); + + it('should open the panel when trigger is clicked', () => { + trigger.click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.select.panelOpen).toBe(true); + expect(overlayContainerElement.textContent).toContain('Steak'); + expect(overlayContainerElement.textContent).toContain('Pizza'); + expect(overlayContainerElement.textContent).toContain('Tacos'); + }); + + it('should close the panel when an item is clicked', async(() => { + trigger.click(); + fixture.detectChanges(); + + const option = overlayContainerElement.querySelector('md-option') as HTMLElement; + option.click(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(overlayContainerElement.textContent).toEqual(''); + expect(fixture.componentInstance.select.panelOpen).toBe(false); + }); + })); + + it('should close the panel when a click occurs outside the panel', async(() => { + trigger.click(); + fixture.detectChanges(); + + const backdrop = overlayContainerElement.querySelector('.md-overlay-backdrop') as HTMLElement; + backdrop.click(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(overlayContainerElement.textContent).toEqual(''); + expect(fixture.componentInstance.select.panelOpen).toBe(false); + }); + })); + + it('should set the width of the overlay based on the trigger', () => { + trigger.style.width = '200px'; + trigger.click(); + fixture.detectChanges(); + + const pane = overlayContainerElement.children[0] as HTMLElement; + expect(pane.style.width).toBe('200px'); + }); + }); + + describe('selection logic', () => { + let fixture: ComponentFixture; + let trigger: HTMLElement; + + beforeEach(() => { + fixture = TestBed.createComponent(BasicSelect); + fixture.detectChanges(); + + trigger = fixture.debugElement.query(By.css('.md-select-trigger')).nativeElement; + }); + + it('should display placeholder if no option is selected', () => { + expect(trigger.textContent.trim()).toEqual('Food'); + }); + + it('should focus the first option if no option is selected', async(() => { + trigger.click(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(fixture.componentInstance.select._keyManager.focusedItemIndex).toEqual(0); + }); + })); + + it('should select an option when it is clicked', () => { + trigger.click(); + fixture.detectChanges(); + + let option = overlayContainerElement.querySelector('md-option') as HTMLElement; + option.click(); + fixture.detectChanges(); + + trigger.click(); + fixture.detectChanges(); + + option = overlayContainerElement.querySelector('md-option') as HTMLElement; + expect(option.classList).toContain('md-selected'); + expect(fixture.componentInstance.options.first.selected).toBe(true); + expect(fixture.componentInstance.select.selected) + .toBe(fixture.componentInstance.options.first); + }); + + it('should deselect other options when one is selected', () => { + trigger.click(); + fixture.detectChanges(); + + let options = + overlayContainerElement.querySelectorAll('md-option') as NodeListOf; + + options[0].click(); + fixture.detectChanges(); + + trigger.click(); + fixture.detectChanges(); + + options = + overlayContainerElement.querySelectorAll('md-option') as NodeListOf; + expect(options[1].classList).not.toContain('md-selected'); + expect(options[2].classList).not.toContain('md-selected'); + + const optionInstances = fixture.componentInstance.options.toArray(); + expect(optionInstances[1].selected).toBe(false); + expect(optionInstances[2].selected).toBe(false); + }); + + it('should display the selected option in the trigger', () => { + trigger.click(); + fixture.detectChanges(); + + const option = overlayContainerElement.querySelector('md-option') as HTMLElement; + option.click(); + fixture.detectChanges(); + + const value = fixture.debugElement.query(By.css('.md-select-value')).nativeElement; + const placeholder = + fixture.debugElement.query(By.css('.md-select-placeholder')).nativeElement; + + expect(placeholder.textContent).toContain('Food'); + expect(value.textContent).toContain('Steak'); + expect(trigger.textContent).toContain('Food'); + expect(trigger.textContent).toContain('Steak'); + }); + + it('should focus the selected option if an option is selected', async(() => { + trigger.click(); + fixture.detectChanges(); + + const options = + overlayContainerElement.querySelectorAll('md-option') as NodeListOf; + options[1].click(); + fixture.detectChanges(); + + trigger.click(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(fixture.componentInstance.select._keyManager.focusedItemIndex).toEqual(1); + }); + })); + + it('should select an option that was added after initialization', () => { + fixture.componentInstance.foods.push({viewValue: 'Pasta'}); + trigger.click(); + fixture.detectChanges(); + + const options = + overlayContainerElement.querySelectorAll('md-option') as NodeListOf; + options[3].click(); + fixture.detectChanges(); + + expect(trigger.textContent).toContain('Pasta'); + expect(fixture.componentInstance.select.selected) + .toBe(fixture.componentInstance.options.last); + }); + + }); + + describe('animations', () => { + let fixture: ComponentFixture; + let trigger: HTMLElement; + + beforeEach(() => { + fixture = TestBed.createComponent(BasicSelect); + fixture.detectChanges(); + + trigger = fixture.debugElement.query(By.css('.md-select-trigger')).nativeElement; + }); + + it('should float the placeholder when the panel is open', () => { + expect(fixture.componentInstance.select._getPlaceholderState()).toEqual('normal'); + + trigger.click(); + fixture.detectChanges(); + expect(fixture.componentInstance.select._getPlaceholderState()).toEqual('floating-ltr'); + + const backdrop = + overlayContainerElement.querySelector('.md-overlay-backdrop') as HTMLElement; + backdrop.click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.select._getPlaceholderState()).toEqual('normal'); + }); + + it('should float the placeholder when there is a selection', () => { + trigger.click(); + fixture.detectChanges(); + + const option = overlayContainerElement.querySelector('md-option') as HTMLElement; + option.click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.select._getPlaceholderState()).toEqual('floating-ltr'); + }); + + it('should use the floating-rtl state when the dir is rtl', () => { + dir.value = 'rtl'; + + trigger.click(); + fixture.detectChanges(); + expect(fixture.componentInstance.select._getPlaceholderState()).toEqual('floating-rtl'); + }); + + it('should use the ltr panel state when the dir is ltr', () => { + dir.value = 'ltr'; + + trigger.click(); + fixture.detectChanges(); + expect(fixture.componentInstance.select._getPanelState()).toEqual('showing-ltr'); + }); + + it('should use the rtl panel state when the dir is rtl', () => { + dir.value = 'rtl'; + + trigger.click(); + fixture.detectChanges(); + expect(fixture.componentInstance.select._getPanelState()).toEqual('showing-rtl'); + }); + + }); + + describe('accessibility', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = TestBed.createComponent(BasicSelect); + fixture.detectChanges(); + }); + + describe('for select', () => { + let select: DebugElement; + + beforeEach(() => { + select = fixture.debugElement.query(By.css('md-select')); + }); + + it('should set the role of the select to listbox', () => { + expect(select.nativeElement.getAttribute('role')).toEqual('listbox'); + }); + + it('should set the aria label of the select to the placeholder', () => { + expect(select.nativeElement.getAttribute('aria-label')).toEqual('Food'); + }); + + it('should set the tabindex of the select to 0', () => { + expect(select.nativeElement.getAttribute('tabindex')).toEqual('0'); + }); + + }); + + describe('for options', () => { + let trigger: HTMLElement; + + beforeEach(() => { + trigger = fixture.debugElement.query(By.css('.md-select-trigger')).nativeElement; + trigger.click(); + fixture.detectChanges(); + }); + + it('should set the role of md-option to option', () => { + const option = overlayContainerElement.querySelector('md-option') as HTMLElement; + expect(option.getAttribute('role')).toEqual('option'); + }); + + it('should set aria-selected on each option', () => { + const options = + overlayContainerElement.querySelectorAll('md-option') as NodeListOf; + expect(options[0].getAttribute('aria-selected')).toEqual('false'); + expect(options[1].getAttribute('aria-selected')).toEqual('false'); + expect(options[2].getAttribute('aria-selected')).toEqual('false'); + + options[1].click(); + fixture.detectChanges(); + + trigger.click(); + fixture.detectChanges(); + + expect(options[0].getAttribute('aria-selected')).toEqual('false'); + expect(options[1].getAttribute('aria-selected')).toEqual('true'); + expect(options[2].getAttribute('aria-selected')).toEqual('false'); + }); + + it('should set the tabindex of each option to 0', () => { + const options = + overlayContainerElement.querySelectorAll('md-option') as NodeListOf; + expect(options[0].getAttribute('tabindex')).toEqual('0'); + expect(options[1].getAttribute('tabindex')).toEqual('0'); + expect(options[2].getAttribute('tabindex')).toEqual('0'); + }); + + }); + + }); + }); -@Component({selector: 'test-select', template: ``}) -class TestSelect {} +@Component({ + selector: 'basic-select', + template: ` + + {{ food.viewValue }} + + ` +}) +class BasicSelect { + foods = [ + { viewValue: 'Steak' }, + { viewValue: 'Pizza' }, + { viewValue: 'Tacos' }, + ]; + + @ViewChild(MdSelect) select: MdSelect; + @ViewChildren(MdOption) options: QueryList; + +} diff --git a/src/lib/select/select.ts b/src/lib/select/select.ts index 7e5cb52374f..b081e82ed7b 100644 --- a/src/lib/select/select.ts +++ b/src/lib/select/select.ts @@ -1,10 +1,229 @@ -import {Component, ViewEncapsulation} from '@angular/core'; +import { + AfterContentInit, + Component, + ContentChildren, + ElementRef, + EventEmitter, + Input, + OnDestroy, + Optional, + Output, + QueryList, + Renderer, + ViewEncapsulation, + ViewChild +} from '@angular/core'; +import {MdOption} from './option'; +import {ENTER, SPACE} from '../core/keyboard/keycodes'; +import {ListKeyManager} from '../core/a11y/list-key-manager'; +import {Dir} from '../core/rtl/dir'; +import {Subscription} from 'rxjs/Subscription'; +import {transformPlaceholder, transformPanel, fadeInContent} from './select-animations'; @Component({ moduleId: module.id, selector: 'md-select', templateUrl: 'select.html', styleUrls: ['select.css'], - encapsulation: ViewEncapsulation.None + encapsulation: ViewEncapsulation.None, + host: { + 'role': 'listbox', + 'tabindex': '0', + '[attr.aria-label]': 'placeholder', + '(keydown)': '_openOnActivate($event)' + }, + animations: [ + transformPlaceholder, + transformPanel, + fadeInContent + ], + exportAs: 'mdSelect' }) -export class MdSelect {} +export class MdSelect implements AfterContentInit, OnDestroy { + /** Whether or not the overlay panel is open. */ + private _panelOpen = false; + + /** The currently selected option. */ + private _selected: MdOption; + + /** Subscriptions to option events. */ + private _subscriptions: Subscription[] = []; + + /** Subscription to changes in the option list. */ + private _changeSubscription: Subscription; + + /** Subscription to tab events while overlay is focused. */ + private _tabSubscription: Subscription; + + /** Manages keyboard events for options in the panel. */ + _keyManager: ListKeyManager; + + /** This position config ensures that the top left corner of the overlay + * is aligned with with the top left of the origin (overlapping the trigger + * completely). In RTL mode, the top right corners are aligned instead. + */ + _positions = [{ + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'top' + }]; + + @ViewChild('trigger') trigger: ElementRef; + @ContentChildren(MdOption) options: QueryList; + + @Input() placeholder: string; + @Output() onOpen = new EventEmitter(); + @Output() onClose = new EventEmitter(); + + constructor(private _element: ElementRef, private _renderer: Renderer, + @Optional() private _dir: Dir) {} + + ngAfterContentInit() { + this._initKeyManager(); + this._listenToOptions(); + + this._changeSubscription = this.options.changes.subscribe(() => { + this._dropSubscriptions(); + this._listenToOptions(); + }); + } + + ngOnDestroy() { + this._dropSubscriptions(); + this._changeSubscription.unsubscribe(); + this._tabSubscription.unsubscribe(); + } + + /** Toggles the overlay panel open or closed. */ + toggle(): void { + this.panelOpen ? this.close() : this.open(); + } + + /** Opens the overlay panel. */ + open(): void { + this._panelOpen = true; + } + + /** Closes the overlay panel. */ + close(): void { + this._panelOpen = false; + } + + /** Whether or not the overlay panel is open. */ + get panelOpen(): boolean { + return this._panelOpen; + } + + /** The currently selected option. */ + get selected(): MdOption { + return this._selected; + } + + _isRtl(): boolean { + return this._dir ? this._dir.value === 'rtl' : false; + } + + /** The width of the trigger element. This is necessary to match + * the overlay width to the trigger width. + */ + _getWidth(): number { + return this.trigger.nativeElement.getBoundingClientRect().width; + } + + /** The animation state of the placeholder. */ + _getPlaceholderState(): string { + if (this.panelOpen || this.selected) { + return this._isRtl() ? 'floating-rtl' : 'floating-ltr'; + } else { + return 'normal'; + } + } + + /** The animation state of the overlay panel. */ + _getPanelState(): string { + return this._isRtl() ? 'showing-rtl' : 'showing-ltr'; + } + + /** Ensures the panel opens if activated by the keyboard. */ + _openOnActivate(event: KeyboardEvent): void { + if (event.keyCode === ENTER || event.keyCode === SPACE) { + this.open(); + } + } + + /** Emits an event and sets focus when overlay panel is finished animating. */ + _onPanelDone(): void { + if (this.panelOpen) { + this._focusCorrectOption(); + this.onOpen.emit(); + } else { + this._focusHost(); + this.onClose.emit(); + } + } + + /** Sets up a key manager to listen to keyboard events on the overlay panel. */ + private _initKeyManager() { + this._keyManager = new ListKeyManager(this.options); + this._tabSubscription = this._keyManager.tabOut.subscribe(() => { + this.close(); + }); + } + + /** Listens to selection events on each option. */ + private _listenToOptions(): void { + this.options.forEach((option: MdOption) => { + const sub = option.onSelect.subscribe(() => this._onSelect(option)); + this._subscriptions.push(sub); + }); + } + + /** Unsubscribes from all option subscriptions. */ + private _dropSubscriptions(): void { + this._subscriptions.forEach((sub: Subscription) => sub.unsubscribe()); + this._subscriptions = []; + } + + /** When a new option is selected, deselects the others and closes the panel. */ + private _onSelect(option: MdOption): void { + this._selected = option; + this._updateOptions(); + this.close(); + } + + /** Deselect each option that doesn't match the current selection. */ + private _updateOptions(): void { + this.options.forEach((option: MdOption) => { + if (option !== this.selected) { + option.deselect(); + } + }); + } + + /** Focuses the selected item. If no option is selected, it will focus + * the first item instead. + */ + private _focusCorrectOption(): void { + if (this.selected) { + this._keyManager.focusedItemIndex = this._getOptionIndex(this.selected); + this.selected.focus(); + } else { + this._keyManager.focusedItemIndex = 0; + this.options.first.focus(); + } + } + + /** Focuses the host element when the panel closes. */ + private _focusHost(): void { + this._renderer.invokeElementMethod(this._element.nativeElement, 'focus'); + } + + /** Gets the index of the provided option in the option list. */ + private _getOptionIndex(option: MdOption): number { + return this.options.reduce((result: number, current: MdOption, index: number) => { + return result === undefined ? (option === current ? index : undefined) : result; + }, undefined); + } + +}