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.
+
@@ -43,5 +43,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 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);