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);
+ }
+
+}