diff --git a/src/demo-app/select/select-demo.html b/src/demo-app/select/select-demo.html
index fbb317a33f89..54508223a18b 100644
--- a/src/demo-app/select/select-demo.html
+++ b/src/demo-app/select/select-demo.html
@@ -113,5 +113,20 @@
+
+
+
+
+
+
+
+ {{ drink.viewValue }}
+
+
+
+
+
+
This div is for testing scrolled selects.
diff --git a/src/demo-app/select/select-demo.ts b/src/demo-app/select/select-demo.ts
index 6318b2a6272c..b0eb9882b3b1 100644
--- a/src/demo-app/select/select-demo.ts
+++ b/src/demo-app/select/select-demo.ts
@@ -17,6 +17,7 @@ export class SelectDemo {
currentDrink: string;
currentPokemon: string[];
currentPokemonFromGroup: string;
+ searchTerm: string;
latestChangeEvent: MdSelectChange;
floatPlaceholder: string = 'auto';
foodControl = new FormControl('pizza-1');
@@ -43,6 +44,8 @@ export class SelectDemo {
{value: 'milk-8', viewValue: 'Milk'},
];
+ filteredDrinks = this.drinks.slice();
+
pokemon = [
{value: 'bulbasaur-0', viewValue: 'Bulbasaur'},
{value: 'charizard-1', viewValue: 'Charizard'},
@@ -101,4 +104,10 @@ export class SelectDemo {
setPokemonValue() {
this.currentPokemon = ['eevee-4', 'psyduck-6'];
}
+
+ filterDrinks() {
+ this.filteredDrinks = this.searchTerm ? this.drinks.filter(item => {
+ return item.viewValue.toLowerCase().indexOf(this.searchTerm.toLowerCase()) > -1;
+ }) : this.drinks.slice();
+ }
}
diff --git a/src/examples/select-header/select-header-example.css b/src/examples/select-header/select-header-example.css
new file mode 100644
index 000000000000..7432308753e6
--- /dev/null
+++ b/src/examples/select-header/select-header-example.css
@@ -0,0 +1 @@
+/** No CSS for this example */
diff --git a/src/examples/select-header/select-header-example.html b/src/examples/select-header/select-header-example.html
new file mode 100644
index 000000000000..943ca3ea29ec
--- /dev/null
+++ b/src/examples/select-header/select-header-example.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+ {{food.viewValue}}
+
+
+
+ Selected value: {{selectedValue}}
diff --git a/src/examples/select-header/select-header-example.ts b/src/examples/select-header/select-header-example.ts
new file mode 100644
index 000000000000..5ed5747d1522
--- /dev/null
+++ b/src/examples/select-header/select-header-example.ts
@@ -0,0 +1,30 @@
+import {Component} from '@angular/core';
+
+
+@Component({
+ selector: 'select-form-example',
+ templateUrl: './select-form-example.html',
+})
+export class SelectHeaderExample {
+ selectedValue: string;
+ searchString: string;
+
+ initialFoods = [
+ { value: 'steak-0', viewValue: 'Steak' },
+ { value: 'pizza-1', viewValue: 'Pizza' },
+ { value: 'tacos-2', viewValue: 'Tacos' },
+ { value: 'sandwich-3', viewValue: 'Sandwich' },
+ { value: 'chips-4', viewValue: 'Chips' },
+ { value: 'eggs-5', viewValue: 'Eggs' },
+ { value: 'pasta-6', viewValue: 'Pasta' },
+ { value: 'sushi-7', viewValue: 'Sushi' },
+ ];
+
+ foods = this.initialFoods.slice();
+
+ filterFoods() {
+ this.foods = this.searchString ? this.initialFoods.filter(item => {
+ return item.viewValue.toLowerCase().indexOf(this.searchString.toLowerCase()) > -1;
+ }) : this.initialFoods.slice();
+ }
+}
diff --git a/src/lib/core/style/_menu-common.scss b/src/lib/core/style/_menu-common.scss
index 6eea82eb1f96..336bb687906d 100644
--- a/src/lib/core/style/_menu-common.scss
+++ b/src/lib/core/style/_menu-common.scss
@@ -15,11 +15,10 @@ $mat-menu-icon-margin: 16px !default;
@mixin mat-menu-base() {
@include mat-elevation(8);
+ @include mat-menu-scrollable();
+
min-width: $mat-menu-overlay-min-width;
max-width: $mat-menu-overlay-max-width;
-
- overflow: auto;
- -webkit-overflow-scrolling: touch; // for momentum scroll on mobile
}
@mixin mat-menu-item-base() {
@@ -91,3 +90,8 @@ $mat-menu-icon-margin: 16px !default;
}
}
}
+
+@mixin mat-menu-scrollable() {
+ overflow: auto;
+ -webkit-overflow-scrolling: touch; // for momentum scroll on mobile
+}
diff --git a/src/lib/select/_select-theme.scss b/src/lib/select/_select-theme.scss
index 4033a4716b97..5610237cedfb 100644
--- a/src/lib/select/_select-theme.scss
+++ b/src/lib/select/_select-theme.scss
@@ -26,6 +26,10 @@
color: mat-color($foreground, hint-text);
}
+ .mat-select-header {
+ color: mat-color($foreground, divider);
+ }
+
.mat-select-underline {
background-color: mat-color($foreground, divider);
}
diff --git a/src/lib/select/index.ts b/src/lib/select/index.ts
index de0337e3a297..6d4b4486cc5b 100644
--- a/src/lib/select/index.ts
+++ b/src/lib/select/index.ts
@@ -9,6 +9,7 @@
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {MdSelect} from './select';
+import {MdSelectHeader} from './select-header';
import {MdCommonModule, OverlayModule, MdOptionModule} from '../core';
@@ -19,11 +20,12 @@ import {MdCommonModule, OverlayModule, MdOptionModule} from '../core';
MdOptionModule,
MdCommonModule,
],
- exports: [MdSelect, MdOptionModule, MdCommonModule],
- declarations: [MdSelect],
+ exports: [MdSelect, MdSelectHeader, MdOptionModule, MdCommonModule],
+ declarations: [MdSelect, MdSelectHeader],
})
export class MdSelectModule {}
export * from './select';
+export * from './select-header';
export {fadeInContent, transformPanel, transformPlaceholder} from './select-animations';
diff --git a/src/lib/select/select-header.ts b/src/lib/select/select-header.ts
new file mode 100644
index 000000000000..dff8e915b9c0
--- /dev/null
+++ b/src/lib/select/select-header.ts
@@ -0,0 +1,21 @@
+/**
+ * @license
+ * Copyright Google Inc. All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.io/license
+ */
+
+import {Directive} from '@angular/core';
+
+
+/**
+ * Fixed header that will be rendered above a select's options.
+ */
+@Directive({
+ selector: 'md-select-header, mat-select-header',
+ host: {
+ 'class': 'mat-select-header',
+ }
+})
+export class MdSelectHeader { }
diff --git a/src/lib/select/select.html b/src/lib/select/select.html
index 0eb861f9c22f..4082da2cda8b 100644
--- a/src/lib/select/select.html
+++ b/src/lib/select/select.html
@@ -35,7 +35,12 @@
[style.transformOrigin]="_transformOrigin"
[class.mat-select-panel-done-animating]="_panelDoneAnimating">
-
diff --git a/src/lib/select/select.scss b/src/lib/select/select.scss
index d7ca055905d9..0380d5b9aca0 100644
--- a/src/lib/select/select.scss
+++ b/src/lib/select/select.scss
@@ -117,10 +117,8 @@ $mat-select-panel-max-height: 256px !default;
margin: 0 $mat-select-arrow-margin;
}
-.mat-select-panel {
- @include mat-menu-base();
- padding-top: 0;
- padding-bottom: 0;
+.mat-select-content {
+ @include mat-menu-scrollable();
max-height: $mat-select-panel-max-height;
min-width: 100%; // prevents some animation twitching and test inconsistencies in IE11
@@ -128,3 +126,24 @@ $mat-select-panel-max-height: 256px !default;
outline: solid 1px;
}
}
+
+.mat-select-panel {
+ @include mat-menu-base();
+ border: none;
+}
+
+.mat-select-header {
+ @include mat-menu-item-base();
+ border-bottom: solid 1px;
+ box-sizing: border-box;
+
+ input {
+ display: block;
+ width: 100%;
+ height: 100%;
+ border: none;
+ outline: none;
+ padding: 0;
+ background: transparent;
+ }
+}
diff --git a/src/lib/select/select.spec.ts b/src/lib/select/select.spec.ts
index 2811bb1c9854..add1e95c8fb2 100644
--- a/src/lib/select/select.spec.ts
+++ b/src/lib/select/select.spec.ts
@@ -63,7 +63,8 @@ describe('MdSelect', () => {
BasicSelectWithTheming,
ResetValuesSelect,
FalsyValueSelect,
- SelectWithGroups
+ SelectWithGroups,
+ BasicSelectWithHeader
],
providers: [
{provide: OverlayContainer, useFactory: () => {
@@ -277,6 +278,17 @@ describe('MdSelect', () => {
expect(panel.classList).toContain('custom-two');
});
+ it('should set an id on the select panel', () => {
+ trigger.click();
+ fixture.detectChanges();
+
+ const panel = document.querySelector('.cdk-overlay-pane .mat-select-content');
+ const instance = fixture.componentInstance.select;
+
+ expect(instance.panelId).toBeTruthy();
+ expect(panel.getAttribute('id')).toBe(instance.panelId);
+ });
+
});
describe('selection logic', () => {
@@ -890,7 +902,7 @@ describe('MdSelect', () => {
trigger.click();
fixture.detectChanges();
- const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-select-panel');
+ const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-select-content');
// The panel should be scrolled to 0 because centering the option is not possible.
expect(scrollContainer.scrollTop).toEqual(0, `Expected panel not to be scrolled.`);
@@ -906,7 +918,7 @@ describe('MdSelect', () => {
trigger.click();
fixture.detectChanges();
- const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-select-panel');
+ const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-select-content');
// The panel should be scrolled to 0 because centering the option is not possible.
expect(scrollContainer.scrollTop).toEqual(0, `Expected panel not to be scrolled.`);
@@ -922,7 +934,7 @@ describe('MdSelect', () => {
trigger.click();
fixture.detectChanges();
- const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-select-panel');
+ const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-select-content');
// The selected option should be scrolled to the center of the panel.
// This will be its original offset from the scrollTop - half the panel height + half the
@@ -942,7 +954,7 @@ describe('MdSelect', () => {
trigger.click();
fixture.detectChanges();
- const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-select-panel');
+ const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-select-content');
// The selected option should be scrolled to the max scroll position.
// This will be the height of the scrollContainer - the panel height.
@@ -972,7 +984,7 @@ describe('MdSelect', () => {
trigger.click();
groupFixture.detectChanges();
- const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-select-panel');
+ const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-select-content');
// The selected option should be scrolled to the center of the panel.
// This will be its original offset from the scrollTop - half the panel height + half the
@@ -1005,7 +1017,7 @@ describe('MdSelect', () => {
trigger.click();
fixture.detectChanges();
- const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-select-panel');
+ const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-select-content');
// Scroll should adjust by the difference between the top space available (85px + 8px
// viewport padding = 77px) and the height of the panel above the option (113px).
@@ -1028,7 +1040,7 @@ describe('MdSelect', () => {
trigger.click();
fixture.detectChanges();
- const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-select-panel');
+ const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-select-content');
// Scroll should adjust by the difference between the bottom space available
// (56px from the bottom of the screen - 8px padding = 48px)
@@ -1055,7 +1067,7 @@ describe('MdSelect', () => {
const overlayPane = document.querySelector('.cdk-overlay-pane');
const triggerBottom = trigger.getBoundingClientRect().bottom;
const overlayBottom = overlayPane.getBoundingClientRect().bottom;
- const scrollContainer = overlayPane.querySelector('.mat-select-panel');
+ const scrollContainer = overlayPane.querySelector('.mat-select-content');
// Expect no scroll to be attempted
expect(scrollContainer.scrollTop).toEqual(0, `Expected panel not to be scrolled.`);
@@ -1082,7 +1094,7 @@ describe('MdSelect', () => {
const overlayPane = document.querySelector('.cdk-overlay-pane');
const triggerTop = trigger.getBoundingClientRect().top;
const overlayTop = overlayPane.getBoundingClientRect().top;
- const scrollContainer = overlayPane.querySelector('.mat-select-panel');
+ const scrollContainer = overlayPane.querySelector('.mat-select-content');
// Expect scroll to remain at the max scroll position
expect(scrollContainer.scrollTop).toEqual(128, `Expected panel to be at max scroll.`);
@@ -1459,6 +1471,51 @@ describe('MdSelect', () => {
});
+ describe('with header', () => {
+ let headerFixture: ComponentFixture;
+
+ beforeEach(() => {
+ headerFixture = TestBed.createComponent(BasicSelectWithHeader);
+ headerFixture.detectChanges();
+ trigger = headerFixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement;
+ select = headerFixture.debugElement.query(By.css('md-select')).nativeElement;
+
+ select.style.marginTop = '300px';
+ select.style.marginLeft = '20px';
+ select.style.marginRight = '20px';
+ });
+
+ it('should account for the header when there is no value', () => {
+ trigger.click();
+ headerFixture.detectChanges();
+
+ const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-select-content');
+
+ expect(scrollContainer.scrollTop).toEqual(0, `Expected panel not to be scrolled.`);
+ checkTriggerAlignedWithOption(0, headerFixture.componentInstance.select);
+ });
+
+ it('should align a selected option in the middle with the trigger text', () => {
+ // Select the fifth option, which has enough space to scroll to the center
+ headerFixture.componentInstance.control.setValue('chips-4');
+ headerFixture.detectChanges();
+
+ trigger.click();
+ headerFixture.detectChanges();
+
+ const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-select-content');
+
+ // The selected option should be scrolled to the center of the panel.
+ // This will be its original offset from the scrollTop - half the panel height + half the
+ // option height. 4 (index) * 48 (option height) = 192px offset from scrollTop
+ // 192 - 256/2 + 48/2 = 88px
+ expect(scrollContainer.scrollTop)
+ .toEqual(88, `Expected overlay panel to be scrolled to center the selected option.`);
+
+ checkTriggerAlignedWithOption(4, headerFixture.componentInstance.select);
+ });
+ });
+
});
describe('accessibility', () => {
@@ -2801,3 +2858,34 @@ class SelectWithGroups {
@ViewChild(MdSelect) select: MdSelect;
@ViewChildren(MdOption) options: QueryList;
}
+
+@Component({
+ selector: 'basic-select-with-header',
+ template: `
+
+
+
+
+
+
+ {{ food.viewValue }}
+
+
+ `
+})
+class BasicSelectWithHeader {
+ foods: any[] = [
+ { value: 'steak-0', viewValue: 'Steak' },
+ { value: 'pizza-1', viewValue: 'Pizza' },
+ { value: 'tacos-2', viewValue: 'Tacos' },
+ { value: 'sandwich-3', viewValue: 'Sandwich' },
+ { value: 'chips-4', viewValue: 'Chips' },
+ { value: 'eggs-5', viewValue: 'Eggs' },
+ { value: 'pasta-6', viewValue: 'Pasta' },
+ { value: 'sushi-7', viewValue: 'Sushi' },
+ ];
+ control = new FormControl();
+
+ @ViewChild(MdSelect) select: MdSelect;
+ @ViewChildren(MdOption) options: QueryList;
+}
diff --git a/src/lib/select/select.ts b/src/lib/select/select.ts
index 7be1cf9f60e8..f64cffd086fa 100644
--- a/src/lib/select/select.ts
+++ b/src/lib/select/select.ts
@@ -9,6 +9,7 @@
import {
AfterContentInit,
Component,
+ ContentChild,
ContentChildren,
ElementRef,
EventEmitter,
@@ -28,6 +29,7 @@ import {
} from '@angular/core';
import {MdOption, MdOptionSelectionChange, MdOptgroup} from '../core/option/index';
import {ENTER, SPACE, UP_ARROW, DOWN_ARROW, HOME, END} from '../core/keyboard/keycodes';
+import {MdSelectHeader} from './select-header';
import {FocusKeyManager} from '../core/a11y/focus-key-manager';
import {Dir} from '../core/rtl/dir';
import {Observable} from 'rxjs/Observable';
@@ -116,6 +118,9 @@ export class MdSelectBase {
}
export const _MdSelectMixinBase = mixinColor(MdSelectBase, 'primary');
+/** Counter for unique panel IDs. */
+let panelIds = 0;
+
@Component({
moduleId: module.id,
selector: 'md-select, mat-select',
@@ -209,6 +214,9 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
/** The IDs of child options to be passed to the aria-owns attribute. */
_optionIds: string = '';
+ /** Unique ID for the panel element. Useful for a11y in projected content (e.g. the header). */
+ panelId: string = 'md-select-panel-' + panelIds++;
+
/** The value of the select panel's transform-origin property. */
_transformOrigin: string = 'top';
@@ -258,6 +266,9 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
/** Classes to be passed to the select panel. Supports the same syntax as `ngClass`. */
@Input() panelClass: string|string[]|Set|{[key: string]: any};
+ /** The select's header, if specified. */
+ @ContentChild(MdSelectHeader) header: MdSelectHeader;
+
/** Placeholder to be shown if no value has been selected. */
@Input()
get placeholder() { return this._placeholder; }
@@ -566,7 +577,7 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
*/
private _setScrollTop(): void {
const scrollContainer =
- this.overlayDir.overlayRef.overlayElement.querySelector('.mat-select-panel');
+ this.overlayDir.overlayRef.overlayElement.querySelector('.mat-select-content');
scrollContainer.scrollTop = this._scrollTop;
}
@@ -799,7 +810,8 @@ 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.
- this._offsetY = (SELECT_ITEM_HEIGHT - SELECT_TRIGGER_HEIGHT) / 2 * -1;
+ this._offsetY = (SELECT_ITEM_HEIGHT - SELECT_TRIGGER_HEIGHT) / 2 * -1 -
+ (this.header ? SELECT_ITEM_HEIGHT : 0);
}
this._checkOverlayWithinViewport(maxScroll);
@@ -932,7 +944,8 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
// The final offset is the option's offset from the top, adjusted for the height
// difference, multiplied by -1 to ensure that the overlay moves in the correct
// direction up the page.
- return optionOffsetFromPanelTop * -1 - SELECT_OPTION_HEIGHT_ADJUSTMENT;
+ return optionOffsetFromPanelTop * -1 - SELECT_OPTION_HEIGHT_ADJUSTMENT -
+ (this.header ? SELECT_ITEM_HEIGHT : 0);
}
/**
diff --git a/src/material-examples/example-module.ts b/src/material-examples/example-module.ts
index 4af008e27335..98261a3b0e21 100644
--- a/src/material-examples/example-module.ts
+++ b/src/material-examples/example-module.ts
@@ -70,6 +70,7 @@ import {SelectOverviewExample} from './select-overview/select-overview-example';
import {ChipsOverviewExample} from './chips-overview/chips-overview-example';
import {ChipsStackedExample} from './chips-stacked/chips-stacked-example';
import {SelectFormExample} from './select-form/select-form-example';
+import {SelectHeaderExample} from './select-header/select-header-example';
import {DatepickerOverviewExample} from './datepicker-overview/datepicker-overview-example';
import {
MdAutocompleteModule, MdButtonModule, MdButtonToggleModule, MdCardModule, MdCheckboxModule,
@@ -79,6 +80,7 @@ import {
MdToolbarModule, MdTooltipModule
} from '@angular/material';
+
export interface LiveExample {
title: string;
component: any;
@@ -152,6 +154,7 @@ export const EXAMPLE_COMPONENTS = {
'radio-overview': {title: 'Basic radios', component: RadioOverviewExample},
'select-overview': {title: 'Basic select', component: SelectOverviewExample},
'select-form': {title: 'Select in a form', component: SelectFormExample},
+ 'select-header': {title: 'Select header', component: SelectHeaderExample},
'sidenav-fab': {title: 'Sidenav with a FAB', component: SidenavFabExample},
'sidenav-overview': {title: 'Basic sidenav', component: SidenavOverviewExample},
'slider-configurable': {title: 'Configurable slider', component: SliderConfigurableExample},
@@ -249,6 +252,7 @@ export const EXAMPLE_LIST = [
SidenavFabExample,
SelectOverviewExample,
SelectFormExample,
+ SelectHeaderExample,
SidenavOverviewExample,
SliderConfigurableExample,
SliderOverviewExample,