diff --git a/src/demo-app/select/select-demo.html b/src/demo-app/select/select-demo.html
index 4471ebc92c54..bc679eb4d502 100644
--- a/src/demo-app/select/select-demo.html
+++ b/src/demo-app/select/select-demo.html
@@ -1,4 +1,4 @@
-
This div is for testing scrolled selects.
+
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 0b369e0c137b..9ca7913cdd5a 100644
--- a/src/demo-app/select/select-demo.ts
+++ b/src/demo-app/select/select-demo.ts
@@ -13,6 +13,7 @@ export class SelectDemo {
isDisabled = false;
showSelect = false;
currentDrink: string;
+ searchTerm: string;
latestChangeEvent: MdSelectChange;
foodControl = new FormControl('pizza-1');
@@ -34,6 +35,8 @@ export class SelectDemo {
{value: 'milk-8', viewValue: 'Milk'},
];
+ filteredDrinks = this.drinks.slice();
+
pokemon = [
{value: 'bulbasaur-0', viewValue: 'Bulbasaur'},
{value: 'charizard-1', viewValue: 'Charizard'},
@@ -43,4 +46,10 @@ export class SelectDemo {
toggleDisabled() {
this.foodControl.enabled ? this.foodControl.disable() : this.foodControl.enable();
}
+
+ 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/lib/core/style/_menu-common.scss b/src/lib/core/style/_menu-common.scss
index 2c97fefb2d6b..1c4b59502119 100644
--- a/src/lib/core/style/_menu-common.scss
+++ b/src/lib/core/style/_menu-common.scss
@@ -14,11 +14,10 @@ $mat-menu-side-padding: 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() {
@@ -87,3 +86,8 @@ $mat-menu-side-padding: 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 3b009f5306ba..88849f0864ec 100644
--- a/src/lib/select/_select-theme.scss
+++ b/src/lib/select/_select-theme.scss
@@ -54,4 +54,8 @@
color: mat-color($foreground, hint-text);
}
}
+
+ .mat-select-header {
+ color: mat-color($foreground, divider);
+ }
}
diff --git a/src/lib/select/index.ts b/src/lib/select/index.ts
index 1a2e4afe6a88..50c08af88287 100644
--- a/src/lib/select/index.ts
+++ b/src/lib/select/index.ts
@@ -1,6 +1,7 @@
import {NgModule, ModuleWithProviders} from '@angular/core';
import {CommonModule} from '@angular/common';
import {MdSelect} from './select';
+import {MdSelectHeader} from './select-header';
import {MdOptionModule} from '../core/option/option';
import {
CompatibilityModule,
@@ -12,8 +13,8 @@ export {fadeInContent, transformPanel, transformPlaceholder} from './select-anim
@NgModule({
imports: [CommonModule, OverlayModule, MdOptionModule, CompatibilityModule],
- exports: [MdSelect, MdOptionModule, CompatibilityModule],
- declarations: [MdSelect],
+ exports: [MdSelect, MdSelectHeader, MdOptionModule, CompatibilityModule],
+ declarations: [MdSelect, MdSelectHeader],
})
export class MdSelectModule {
/** @deprecated */
diff --git a/src/lib/select/select-header.ts b/src/lib/select/select-header.ts
new file mode 100644
index 000000000000..0a53c7db8033
--- /dev/null
+++ b/src/lib/select/select-header.ts
@@ -0,0 +1,13 @@
+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 4e36b2187800..1a2c7387030e 100644
--- a/src/lib/select/select.html
+++ b/src/lib/select/select.html
@@ -14,7 +14,13 @@
-
diff --git a/src/lib/select/select.scss b/src/lib/select/select.scss
index e024c72e12ec..46a47e0ebe05 100644
--- a/src/lib/select/select.scss
+++ b/src/lib/select/select.scss
@@ -119,10 +119,8 @@ $mat-select-trigger-font-size: 16px !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;
@include cdk-high-contrast {
@@ -130,3 +128,23 @@ $mat-select-trigger-font-size: 16px !default;
}
}
+.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 59c2e5de58da..7d8bb7f4e461 100644
--- a/src/lib/select/select.spec.ts
+++ b/src/lib/select/select.spec.ts
@@ -37,7 +37,8 @@ describe('MdSelect', () => {
CompWithCustomSelect,
SelectWithErrorSibling,
ThrowsErrorOnInit,
- BasicSelectOnPush
+ BasicSelectOnPush,
+ BasicSelectWithHeader,
],
providers: [
{provide: OverlayContainer, useFactory: () => {
@@ -138,6 +139,17 @@ describe('MdSelect', () => {
expect(fixture.componentInstance.select.panelOpen).toBe(false);
});
+ 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', () => {
@@ -648,19 +660,23 @@ describe('MdSelect', () => {
let fixture: ComponentFixture
;
let trigger: HTMLElement;
let select: HTMLElement;
+ let selectInstance: MdSelect;
beforeEach(() => {
fixture = TestBed.createComponent(BasicSelect);
fixture.detectChanges();
trigger = fixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement;
select = fixture.debugElement.query(By.css('md-select')).nativeElement;
+ selectInstance = fixture.componentInstance.select;
});
/**
* Asserts that the given option is aligned with the trigger.
* @param index The index of the option.
+ * @param selectInstance Instance of MdSelect to use when asserting.
*/
function checkTriggerAlignedWithOption(index: number): void {
+
const overlayPane = overlayContainerElement.querySelector('.cdk-overlay-pane');
const triggerTop = trigger.getBoundingClientRect().top;
const overlayTop = overlayPane.getBoundingClientRect().top;
@@ -675,7 +691,7 @@ describe('MdSelect', () => {
// For the animation to start at the option's center, its origin must be the distance
// from the top of the overlay to the option top + half the option height (48/2 = 24).
const expectedOrigin = optionTop - overlayTop + 24;
- expect(fixture.componentInstance.select._transformOrigin)
+ expect(selectInstance._transformOrigin)
.toContain(`${expectedOrigin}px`,
`Expected panel animation to originate in the center of option ${index}.`);
}
@@ -695,7 +711,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.`);
@@ -711,7 +727,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.`);
@@ -727,7 +743,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
@@ -747,7 +763,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.
@@ -779,7 +795,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).
@@ -802,7 +818,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
// (686px - 600px margin - 30px trigger height = 56px - 8px padding = 48px)
@@ -829,7 +845,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.`);
@@ -856,7 +872,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.`);
@@ -1009,6 +1025,52 @@ 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;
+ selectInstance = headerFixture.componentInstance.select;
+
+ 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);
+ });
+
+ 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);
+ });
+ });
+
});
describe('accessibility', () => {
@@ -1529,6 +1591,38 @@ class BasicSelectOnPush {
@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();
+ isRequired: boolean;
+
+ @ViewChild(MdSelect) select: MdSelect;
+ @ViewChildren(MdOption) options: QueryList;
+}
+
/**
* TODO: Move this to core testing utility until Angular has event faking
diff --git a/src/lib/select/select.ts b/src/lib/select/select.ts
index c1c0a3e37978..3e61524198e7 100644
--- a/src/lib/select/select.ts
+++ b/src/lib/select/select.ts
@@ -1,6 +1,7 @@
import {
AfterContentInit,
Component,
+ ContentChild,
ContentChildren,
ElementRef,
EventEmitter,
@@ -16,6 +17,7 @@ import {
ChangeDetectorRef,
} from '@angular/core';
import {MdOption, MdOptionSelectEvent} from '../core/option/option';
+import {MdSelectHeader} from './select-header';
import {ENTER, SPACE} from '../core/keyboard/keycodes';
import {FocusKeyManager} from '../core/a11y/focus-key-manager';
import {Dir} from '../core/rtl/dir';
@@ -73,6 +75,10 @@ export class MdSelectChange {
constructor(public source: MdSelect, public value: any) { }
}
+/** Counter for unique panel IDs. */
+let panelIds = 0;
+
+
@Component({
moduleId: module.id,
selector: 'md-select, mat-select',
@@ -154,6 +160,9 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
/** 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';
@@ -204,6 +213,9 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
/** All of the defined select options. */
@ContentChildren(MdOption) options: QueryList;
+ /** 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; }
@@ -410,7 +422,7 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
*/
_setScrollTop(): void {
const scrollContainer =
- this.overlayDir.overlayRef.overlayElement.querySelector('.mat-select-panel');
+ this.overlayDir.overlayRef.overlayElement.querySelector('.mat-select-content');
scrollContainer.scrollTop = this._scrollTop;
}
@@ -464,7 +476,8 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
if (event.isUserInput && this._selected !== option) {
this._emitChangeEvent(option);
}
- this._onSelect(option);
+
+ this._onSelect(event);
});
this._subscriptions.push(sub);
});
@@ -488,12 +501,12 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
}
/** When a new option is selected, deselects the others and closes the panel. */
- private _onSelect(option: MdOption): void {
- this._selected = option;
+ private _onSelect(event: MdOptionSelectEvent): void {
+ this._selected = event.source;
this._updateOptions();
this._setValueWidth();
this._placeholderState = '';
- if (this.panelOpen) {
+ if (this.panelOpen && event.isUserInput) {
this.close();
}
}
@@ -543,9 +556,9 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
private _calculateOverlayPosition(): void {
this._offsetX = this._isRtl() ? SELECT_PANEL_PADDING_X : -SELECT_PANEL_PADDING_X;
- const panelHeight =
- Math.min(this.options.length * SELECT_OPTION_HEIGHT, SELECT_PANEL_MAX_HEIGHT);
- const scrollContainerHeight = this.options.length * SELECT_OPTION_HEIGHT;
+ const menuItems = this.options.length;
+ const panelHeight = Math.min(menuItems * SELECT_OPTION_HEIGHT, SELECT_PANEL_MAX_HEIGHT);
+ const scrollContainerHeight = menuItems * SELECT_OPTION_HEIGHT;
// The farthest the panel can be scrolled before it hits the bottom
const maxScroll = scrollContainerHeight - panelHeight;
@@ -562,7 +575,8 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
// 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_OPTION_HEIGHT - SELECT_TRIGGER_HEIGHT) / 2 * -1;
+ this._offsetY = (SELECT_OPTION_HEIGHT - SELECT_TRIGGER_HEIGHT) / 2 * -1 -
+ (this.header ? SELECT_OPTION_HEIGHT : 0);
}
this._checkOverlayWithinViewport(maxScroll);
@@ -619,7 +633,8 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
// 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_OPTION_HEIGHT : 0);
}
/**
@@ -639,7 +654,7 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
const panelHeightTop = Math.abs(this._offsetY);
const totalPanelHeight =
Math.min(this.options.length * SELECT_OPTION_HEIGHT, SELECT_PANEL_MAX_HEIGHT);
- const panelHeightBottom = totalPanelHeight - panelHeightTop - triggerRect.height;
+ const panelHeightBottom = totalPanelHeight - panelHeightTop - triggerRect.height;
if (panelHeightBottom > bottomSpaceAvailable) {
this._adjustPanelUp(panelHeightBottom, bottomSpaceAvailable);