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