From 2c3d45a21afd5ecd5b6fbb0d762c9c231a32a1c9 Mon Sep 17 00:00:00 2001 From: crisbeto Date: Mon, 16 Oct 2017 22:36:08 +0200 Subject: [PATCH] feat(select): add mat-select-header component Adds a `mat-select-header` component, which is a fixed header above the select's options. It allows for the user to project an input to be used for filtering long lists of options. **Note:** This component only handles the positioning, styling, some basic focus management and exposes the panel id for a11y. The functionality is up to the consumer to handle. Fixes #2812. --- src/demo-app/select/select-demo.html | 25 +++ src/demo-app/select/select-demo.ts | 9 ++ src/lib/core/style/_menu-common.scss | 4 + src/lib/select/_select-theme.scss | 4 + src/lib/select/public-api.ts | 1 + src/lib/select/select-animations.ts | 7 +- src/lib/select/select-header.html | 3 + src/lib/select/select-header.ts | 33 ++++ src/lib/select/select-module.ts | 14 +- src/lib/select/select.html | 15 +- src/lib/select/select.md | 11 +- src/lib/select/select.scss | 31 +++- src/lib/select/select.spec.ts | 150 +++++++++++++++--- src/lib/select/select.ts | 21 ++- src/material-examples/example-module.ts | 8 + .../select-header/select-header-example.css | 1 + .../select-header/select-header-example.html | 12 ++ .../select-header/select-header-example.ts | 30 ++++ 18 files changed, 336 insertions(+), 43 deletions(-) create mode 100644 src/lib/select/select-header.html create mode 100644 src/lib/select/select-header.ts create mode 100644 src/material-examples/select-header/select-header-example.css create mode 100644 src/material-examples/select-header/select-header-example.html create mode 100644 src/material-examples/select-header/select-header-example.ts diff --git a/src/demo-app/select/select-demo.html b/src/demo-app/select/select-demo.html index 1ce8ef756f15..50a9983aba5e 100644 --- a/src/demo-app/select/select-demo.html +++ b/src/demo-app/select/select-demo.html @@ -140,6 +140,31 @@ + + Select header + + + + + + + + + + {{ drink.viewValue }} + + + + + +
formControl diff --git a/src/demo-app/select/select-demo.ts b/src/demo-app/select/select-demo.ts index 1967e60f7c43..b0498d44cc87 100644 --- a/src/demo-app/select/select-demo.ts +++ b/src/demo-app/select/select-demo.ts @@ -20,6 +20,7 @@ export class SelectDemo { currentPokemon: string[]; currentPokemonFromGroup: string; currentDigimon: string; + searchTerm: string; latestChangeEvent: MatSelectChange; floatPlaceholder: string = 'auto'; foodControl = new FormControl('pizza-1'); @@ -47,6 +48,8 @@ export class SelectDemo { {value: 'milk-8', viewValue: 'Milk'}, ]; + filteredDrinks = this.drinks.slice(); + pokemon = [ {value: 'bulbasaur-0', viewValue: 'Bulbasaur'}, {value: 'charizard-1', viewValue: 'Charizard'}, @@ -126,4 +129,10 @@ export class SelectDemo { compareByReference(o1: any, o2: any) { return o1 === o2; } + + 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 74800cfec21f..a459d88d6fd6 100644 --- a/src/lib/core/style/_menu-common.scss +++ b/src/lib/core/style/_menu-common.scss @@ -15,8 +15,12 @@ $mat-menu-icon-margin: 16px !default; @mixin mat-menu-base($default-elevation) { @include mat-overridable-elevation($default-elevation); + @include mat-menu-scrollable(); min-width: $mat-menu-overlay-min-width; max-width: $mat-menu-overlay-max-width; +} + +@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 685f8f2b82a1..7c93b7605769 100644 --- a/src/lib/select/_select-theme.scss +++ b/src/lib/select/_select-theme.scss @@ -31,6 +31,10 @@ } } + .mat-select-header { + color: mat-color($foreground, divider); + } + .mat-form-field { &.mat-focused { &.mat-primary .mat-select-arrow { diff --git a/src/lib/select/public-api.ts b/src/lib/select/public-api.ts index a89d7fe23e80..48d5b1536863 100644 --- a/src/lib/select/public-api.ts +++ b/src/lib/select/public-api.ts @@ -9,3 +9,4 @@ export * from './select-module'; export * from './select'; export * from './select-animations'; +export * from './select-header'; diff --git a/src/lib/select/select-animations.ts b/src/lib/select/select-animations.ts index 434be7c71681..3e7b96ae02f5 100644 --- a/src/lib/select/select-animations.ts +++ b/src/lib/select/select-animations.ts @@ -61,9 +61,8 @@ export const transformPanel: AnimationTriggerMetadata = trigger('transformPanel' * panel has transformed in. */ export const fadeInContent: AnimationTriggerMetadata = trigger('fadeInContent', [ + state('void', style({opacity: 0})), state('showing', style({opacity: 1})), - transition('void => showing', [ - style({opacity: 0}), - animate('150ms 100ms cubic-bezier(0.55, 0, 0.55, 0.2)') - ]) + transition('void => showing', animate('150ms 100ms cubic-bezier(0.55, 0, 0.55, 0.2)')), + transition('showing => void', animate('150ms cubic-bezier(0.55, 0, 0.55, 0.2)')) ]); diff --git a/src/lib/select/select-header.html b/src/lib/select/select-header.html new file mode 100644 index 000000000000..460e0a5b8ad9 --- /dev/null +++ b/src/lib/select/select-header.html @@ -0,0 +1,3 @@ + + + diff --git a/src/lib/select/select-header.ts b/src/lib/select/select-header.ts new file mode 100644 index 000000000000..28a539007ebc --- /dev/null +++ b/src/lib/select/select-header.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright Google LLC 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 {Component, ViewEncapsulation, ChangeDetectionStrategy, ViewChild} from '@angular/core'; +import {FocusTrapDirective} from '@angular/cdk/a11y'; + +/** + * Fixed header that will be rendered above a select's options. + * Can be used as a bar for filtering out options. + */ +@Component({ + moduleId: module.id, + selector: 'mat-select-header', + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + preserveWhitespaces: false, + templateUrl: 'select-header.html', + host: { + 'class': 'mat-select-header', + } +}) +export class MatSelectHeader { + @ViewChild(FocusTrapDirective) _focusTrap: FocusTrapDirective; + + _trapFocus() { + this._focusTrap.focusTrap.focusFirstTabbableElementWhenReady(); + } +} diff --git a/src/lib/select/select-module.ts b/src/lib/select/select-module.ts index 3da48beca6bd..636822b4b573 100644 --- a/src/lib/select/select-module.ts +++ b/src/lib/select/select-module.ts @@ -8,10 +8,12 @@ import {NgModule} from '@angular/core'; import {CommonModule} from '@angular/common'; import {MatSelect, MatSelectTrigger, MAT_SELECT_SCROLL_STRATEGY_PROVIDER} from './select'; +import {MatSelectHeader} from './select-header'; import {MatCommonModule, MatOptionModule} from '@angular/material/core'; import {OverlayModule} from '@angular/cdk/overlay'; import {MatFormFieldModule} from '@angular/material/form-field'; import {ErrorStateMatcher} from '@angular/material/core'; +import {A11yModule} from '@angular/cdk/a11y'; @NgModule({ @@ -20,9 +22,17 @@ import {ErrorStateMatcher} from '@angular/material/core'; OverlayModule, MatOptionModule, MatCommonModule, + A11yModule, ], - exports: [MatFormFieldModule, MatSelect, MatSelectTrigger, MatOptionModule, MatCommonModule], - declarations: [MatSelect, MatSelectTrigger], + exports: [ + MatFormFieldModule, + MatSelect, + MatSelectTrigger, + MatSelectHeader, + MatOptionModule, + MatCommonModule, + ], + declarations: [MatSelect, MatSelectTrigger, MatSelectHeader], providers: [MAT_SELECT_SCROLL_STRATEGY_PROVIDER, ErrorStateMatcher] }) export class MatSelectModule {} diff --git a/src/lib/select/select.html b/src/lib/select/select.html index eb42a39aa6d9..5a4961a0b302 100644 --- a/src/lib/select/select.html +++ b/src/lib/select/select.html @@ -34,20 +34,21 @@ (detach)="close()">
+ [style.font-size.px]="_triggerFontSize" + (keydown)="_handleKeydown($event)"> -
- +
+ + +
+ +
diff --git a/src/lib/select/select.md b/src/lib/select/select.md index b4e9f690b0c0..90e3979d7cb1 100644 --- a/src/lib/select/select.md +++ b/src/lib/select/select.md @@ -67,7 +67,7 @@ on the group. ### Multiple selection -`` defaults to single-selection mode, but can be configured to allow multiple selection +`` defaults to single-selection mode, but can be configured to allow multiple selection by setting the `multiple` property. This will allow the user to select multiple values at once. When using the `` in multiple selection mode, its value will be a sorted list of all selected values rather than a single value. @@ -81,6 +81,15 @@ If you want to display a custom trigger label inside a select, you can use the +### Adding a header + +You can add an extra header that will stay fixed on top of the select's option as the user scrolls. +The header can be used as a filter bar or as an extra title. Note that the accessibility of the +header content is up to the consumer. For example when using it as a filter bar, the `input` element +should have a `role="combobox"` and an `[attr.aria-owns]="select.panelId"`. + + + ### Disabling the ripple effect By default, when a user clicks on a ``, a ripple animation is shown. This can be disabled diff --git a/src/lib/select/select.scss b/src/lib/select/select.scss index 7f7dd9af7f0e..324a51410bc1 100644 --- a/src/lib/select/select.scss +++ b/src/lib/select/select.scss @@ -55,10 +55,8 @@ $mat-select-placeholder-arrow-space: 2 * ($mat-select-arrow-size + $mat-select-a margin: 0 $mat-select-arrow-margin; } -.mat-select-panel { - @include mat-menu-base(8); - 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 @@ -67,10 +65,33 @@ $mat-select-placeholder-arrow-space: 2 * ($mat-select-arrow-size + $mat-select-a } } +.mat-select-panel { + @include mat-menu-base(8); + border: none; +} + +.mat-select-header { + @include mat-menu-item-base(); + border-bottom: solid 1px; + box-sizing: border-box; +} + +// Opt-in header input styling. +.mat-select-header-input { + display: block; + width: 100%; + height: 100%; + border: none; + outline: none; + padding: 0; + background: transparent; +} + // Override optgroup and option to scale based on font-size of the trigger. .mat-select-panel { .mat-optgroup-label, - .mat-option { + .mat-option, + .mat-select-header { font-size: inherit; line-height: $mat-select-item-height; height: $mat-select-item-height; diff --git a/src/lib/select/select.spec.ts b/src/lib/select/select.spec.ts index 9f2732d6a480..2b7f5020d73d 100644 --- a/src/lib/select/select.spec.ts +++ b/src/lib/select/select.spec.ts @@ -19,7 +19,15 @@ import { ViewChild, ViewChildren, } from '@angular/core'; -import {ComponentFixture, fakeAsync, flush, inject, TestBed, tick} from '@angular/core/testing'; +import { + ComponentFixture, + async, + fakeAsync, + flush, + inject, + TestBed, + tick, +} from '@angular/core/testing'; import { ControlValueAccessor, FormControl, @@ -56,7 +64,7 @@ const LETTER_KEY_DEBOUNCE_INTERVAL = 200; const platform = new Platform(); -describe('MatSelect', () => { +fdescribe('MatSelect', () => { let overlayContainerElement: HTMLElement; let dir: {value: 'ltr'|'rtl'}; let scrolledSubject = new Subject(); @@ -105,6 +113,7 @@ describe('MatSelect', () => { NgModelCompareWithSelect, CustomErrorBehaviorSelect, SingleSelectWithPreselectedArrayValues, + BasicSelectWithHeader, ], providers: [ {provide: OverlayContainer, useFactory: () => { @@ -312,6 +321,17 @@ describe('MatSelect', () => { 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); + }); + it('should prevent the default action when pressing SPACE on an option', fakeAsync(() => { trigger.click(); fixture.detectChanges(); @@ -1151,10 +1171,10 @@ describe('MatSelect', () => { fixture.detectChanges(); flush(); - 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.`); + expect(scrollContainer!.scrollTop).toEqual(0, `Expected panel not to be scrolled.`); checkTriggerAlignedWithOption(0); })); @@ -1168,10 +1188,10 @@ describe('MatSelect', () => { fixture.detectChanges(); flush(); - 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.`); + expect(scrollContainer!.scrollTop).toEqual(0, `Expected panel not to be scrolled.`); checkTriggerAlignedWithOption(1); })); @@ -1185,7 +1205,7 @@ describe('MatSelect', () => { fixture.detectChanges(); flush(); - 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 @@ -1207,7 +1227,7 @@ describe('MatSelect', () => { fixture.detectChanges(); flush(); - 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. @@ -1245,7 +1265,7 @@ describe('MatSelect', () => { groupFixture.detectChanges(); flush(); - 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 @@ -1309,9 +1329,9 @@ describe('MatSelect', () => { fixture.detectChanges(); flush(); - const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-select-panel')!; + const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-select-content'); - expect(Math.ceil(scrollContainer.scrollTop)) + expect(Math.ceil(scrollContainer!.scrollTop)) .toEqual(Math.ceil(idealScrollTop + 5), `Expected panel to adjust scroll position to fit in viewport.`); @@ -1367,13 +1387,13 @@ describe('MatSelect', () => { fixture.detectChanges(); flush(); - 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) // and the height of the panel below the option (113px). // 113px - 48px = 75px difference. Original scrollTop 88px - 75px = 23px - const difference = Math.ceil(scrollContainer.scrollTop) - + const difference = Math.ceil(scrollContainer!.scrollTop) - Math.ceil(idealScrollTop - expectedExtraScroll); // Note that different browser/OS combinations report the different dimensions with @@ -1402,7 +1422,7 @@ describe('MatSelect', () => { 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.`); @@ -1435,7 +1455,7 @@ describe('MatSelect', () => { 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.`); @@ -1461,7 +1481,8 @@ describe('MatSelect', () => { fixture.detectChanges(); flush(); - const panelLeft = document.querySelector('.mat-select-panel')!.getBoundingClientRect().left; + const panelLeft = + document.querySelector('.mat-select-content')!.getBoundingClientRect().left; expect(panelLeft).toBeGreaterThan(0, `Expected select panel to be inside the viewport in ltr.`); @@ -1474,7 +1495,8 @@ describe('MatSelect', () => { fixture.detectChanges(); flush(); - const panelLeft = document.querySelector('.mat-select-panel')!.getBoundingClientRect().left; + const panelLeft = + document.querySelector('.mat-select-content')!.getBoundingClientRect().left; expect(panelLeft).toBeGreaterThan(0, `Expected select panel to be inside the viewport in rtl.`); @@ -1487,7 +1509,7 @@ describe('MatSelect', () => { flush(); const viewportRect = viewportRuler.getViewportRect().right; - const panelRight = document.querySelector('.mat-select-panel')! + const panelRight = document.querySelector('.mat-select-content')! .getBoundingClientRect().right; expect(viewportRect - panelRight).toBeGreaterThan(0, @@ -1502,7 +1524,7 @@ describe('MatSelect', () => { flush(); const viewportRect = viewportRuler.getViewportRect().right; - const panelRight = document.querySelector('.mat-select-panel')! + const panelRight = document.querySelector('.mat-select-content')! .getBoundingClientRect().right; expect(viewportRect - panelRight).toBeGreaterThan(0, @@ -1515,7 +1537,7 @@ describe('MatSelect', () => { fixture.detectChanges(); flush(); - let panelLeft = document.querySelector('.mat-select-panel')!.getBoundingClientRect().left; + let panelLeft = document.querySelector('.mat-select-content')!.getBoundingClientRect().left; expect(panelLeft).toBeGreaterThan(0, `Expected select panel to be inside the viewport.`); @@ -1527,7 +1549,7 @@ describe('MatSelect', () => { fixture.detectChanges(); flush(); - panelLeft = document.querySelector('.mat-select-panel')!.getBoundingClientRect().left; + panelLeft = document.querySelector('.mat-select-content')!.getBoundingClientRect().left; expect(panelLeft).toBeGreaterThan(0, `Expected select panel continue being inside the viewport.`); @@ -1881,6 +1903,55 @@ describe('MatSelect', () => { } })); }); + + 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('mat-select')).nativeElement; + formField = headerFixture.debugElement.query(By.css('mat-form-field')).nativeElement; + + formField.style.position = 'fixed'; + formField.style.top = '300px'; + formField.style.left = '200px'; + }); + + it('should account for the header when there is no value', async(() => { + trigger.click(); + headerFixture.detectChanges(); + + headerFixture.whenStable().then(() => { + 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', async(() => { + // 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(); + + headerFixture.whenStable().then(() => { + const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-select-content')!; + + expect(scrollContainer.scrollTop) + .toEqual(128, `Expected overlay panel to be scrolled to center the selected option.`); + + checkTriggerAlignedWithOption(4, headerFixture.componentInstance.select); + }); + })); + + }); + }); describe('accessibility', () => { @@ -3167,7 +3238,7 @@ describe('MatSelect', () => { flush(); host = fixture.debugElement.query(By.css('mat-select')).nativeElement; - panel = overlayContainerElement.querySelector('.mat-select-panel')! as HTMLElement; + panel = overlayContainerElement.querySelector('.mat-select-content')! as HTMLElement; })); it('should not scroll to options that are completely in the view', fakeAsync(() => { @@ -3214,7 +3285,7 @@ describe('MatSelect', () => { flush(); host = groupFixture.debugElement.query(By.css('mat-select')).nativeElement; - panel = overlayContainerElement.querySelector('.mat-select-panel')! as HTMLElement; + panel = overlayContainerElement.querySelector('.mat-select-content')! as HTMLElement; for (let i = 0; i < 5; i++) { dispatchKeyboardEvent(host, 'keydown', DOWN_ARROW); @@ -3953,3 +4024,36 @@ class SingleSelectWithPreselectedArrayValues { @ViewChild(MatSelect) select: MatSelect; @ViewChildren(MatOption) options: QueryList; } + +@Component({ + selector: 'basic-select-with-header', + template: ` + + + + + + + + {{ food.viewValue }} + + + + ` +}) +class BasicSelectWithHeader { + foods = [ + {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(MatSelect) select: MatSelect; + @ViewChildren(MatOption) options: QueryList; +} diff --git a/src/lib/select/select.ts b/src/lib/select/select.ts index 5688af37e37f..be69e0b18818 100644 --- a/src/lib/select/select.ts +++ b/src/lib/select/select.ts @@ -75,6 +75,7 @@ import {Observable} from 'rxjs/Observable'; import {merge} from 'rxjs/observable/merge'; import {Subject} from 'rxjs/Subject'; import {fadeInContent, transformPanel} from './select-animations'; +import {MatSelectHeader} from './select-header'; import { getMatSelectDynamicMultipleError, getMatSelectNonArrayValueError, @@ -147,7 +148,6 @@ export class MatSelectBase { } export const _MatSelectMixinBase = mixinTabIndex(mixinDisabled(MatSelectBase)); - /** * Allows the user to customize the trigger that is displayed when the select has a value. */ @@ -297,6 +297,9 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit, /** A name for this control that can be used by `mat-form-field`. */ controlType = 'mat-select'; + /** Unique ID for the panel element. Useful for a11y in projected content (e.g. the header). */ + panelId: string = 'mat-select-panel-' + nextUniqueId++; + /** Trigger that opens the select. */ @ViewChild('trigger') trigger: ElementRef; @@ -318,6 +321,9 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit, /** User-supplied override of the trigger element. */ @ContentChild(MatSelectTrigger) customTrigger: MatSelectTrigger; + /** The select's header, if specified. */ + @ContentChild(MatSelectHeader) header: MatSelectHeader; + /** Placeholder to be shown if no value has been selected. */ @Input() get placeholder() { return this._placeholder; } @@ -673,6 +679,12 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit, if (this.panelOpen) { this._scrollTop = 0; this.openedChange.emit(true); + + if (this.header) { + // Move focus into the header, if we have one, + // otherwise it'll be left on the select trigger. + this.header._trapFocus(); + } } else { this.openedChange.emit(false); this._panelDoneAnimating = false; @@ -964,9 +976,16 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit, let selectedOptionOffset = this.empty ? 0 : this._getOptionIndex(this._selectionModel.selected[0])!; + // Add the amount of groups that come before the option to the offset. selectedOptionOffset += MatOption.countGroupLabelsBeforeOption(selectedOptionOffset, this.options, this.optionGroups); + // If we have a header, we need to add one to the offset, because + // the header will push the option down by one. + if (this.header) { + selectedOptionOffset += 1; + } + // We must maintain a scroll buffer so the selected option will be scrolled to the // center of the overlay panel rather than the top. const scrollBuffer = panelHeight / 2; diff --git a/src/material-examples/example-module.ts b/src/material-examples/example-module.ts index 1fd0719e1055..76303a9933c2 100644 --- a/src/material-examples/example-module.ts +++ b/src/material-examples/example-module.ts @@ -96,6 +96,7 @@ import {SelectOverviewExample} from './select-overview/select-overview-example'; import {SelectPanelClassExample} from './select-panel-class/select-panel-class-example'; import {SelectResetExample} from './select-reset/select-reset-example'; import {SelectValueBindingExample} from './select-value-binding/select-value-binding-example'; +import {SelectHeaderExample} from './select-header/select-header-example'; import {SidenavFabExample} from './sidenav-fab/sidenav-fab-example'; import {SidenavOverviewExample} from './sidenav-overview/sidenav-overview-example'; import {SlideToggleConfigurableExample} from './slide-toggle-configurable/slide-toggle-configurable-example'; @@ -619,6 +620,12 @@ export const EXAMPLE_COMPONENTS = { additionalFiles: null, selectorName: null }, + 'select-header': { + title: 'Select header filtering', + component: SelectHeaderExample, + additionalFiles: null, + selectorName: null + }, 'sidenav-fab': { title: 'Sidenav with a FAB', component: SidenavFabExample, @@ -843,6 +850,7 @@ export const EXAMPLE_LIST = [ SelectPanelClassExample, SelectResetExample, SelectValueBindingExample, + SelectHeaderExample, SidenavFabExample, SidenavOverviewExample, SlideToggleConfigurableExample, diff --git a/src/material-examples/select-header/select-header-example.css b/src/material-examples/select-header/select-header-example.css new file mode 100644 index 000000000000..7432308753e6 --- /dev/null +++ b/src/material-examples/select-header/select-header-example.css @@ -0,0 +1 @@ +/** No CSS for this example */ diff --git a/src/material-examples/select-header/select-header-example.html b/src/material-examples/select-header/select-header-example.html new file mode 100644 index 000000000000..7b0c2a5e0b96 --- /dev/null +++ b/src/material-examples/select-header/select-header-example.html @@ -0,0 +1,12 @@ + + + + + + + {{food.viewValue}} + + + +

Selected value: {{selectedValue}}

diff --git a/src/material-examples/select-header/select-header-example.ts b/src/material-examples/select-header/select-header-example.ts new file mode 100644 index 000000000000..548a90f19973 --- /dev/null +++ b/src/material-examples/select-header/select-header-example.ts @@ -0,0 +1,30 @@ +import {Component} from '@angular/core'; + + +@Component({ + selector: 'select-header-example', + templateUrl: './select-header-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(); + } +}