Skip to content

Commit

Permalink
feat(select): add md-select-header directive
Browse files Browse the repository at this point in the history
Adds a `md-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 and exposes the panel id for a11y. The functionality is up to the user to handle.

Fixes angular#2812.
  • Loading branch information
crisbeto committed Feb 20, 2017
1 parent a02ed5a commit 37b8056
Show file tree
Hide file tree
Showing 10 changed files with 211 additions and 32 deletions.
17 changes: 16 additions & 1 deletion src/demo-app/select/select-demo.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<div style="height: 1000px">This div is for testing scrolled selects.</div>
<!-- <div style="height: 1000px">This div is for testing scrolled selects.</div> -->
<button md-button (click)="showSelect=!showSelect">SHOW SELECT</button>
<div class="demo-select">
<div *ngIf="showSelect">
Expand Down Expand Up @@ -43,5 +43,20 @@
</md-card>
</div>

<md-card *ngIf="showSelect">
<md-select placeholder="Drink" [(ngModel)]="currentDrink" #selectWitHeader="mdSelect">
<md-select-header>
<input type="search" [(ngModel)]="searchTerm" role="combobox" [attr.aria-owns]="selectWitHeader.panelId"
(ngModelChange)="filterDrinks()" placeholder="Search for a drink"/>
</md-select-header>

<md-option *ngFor="let drink of filteredDrinks" [value]="drink.value" [disabled]="drink.disabled">
{{ drink.viewValue }}
</md-option>

</md-select>
</md-card>


</div>
<div style="height: 500px">This div is for testing scrolled selects.</div>
9 changes: 9 additions & 0 deletions src/demo-app/select/select-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export class SelectDemo {
isDisabled = false;
showSelect = false;
currentDrink: string;
searchTerm: string;
latestChangeEvent: MdSelectChange;
foodControl = new FormControl('pizza-1');

Expand All @@ -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'},
Expand All @@ -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();
}
}
10 changes: 7 additions & 3 deletions src/lib/core/style/_menu-common.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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
}
4 changes: 4 additions & 0 deletions src/lib/select/_select-theme.scss
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,8 @@
color: mat-color($foreground, hint-text);
}
}

.mat-select-header {
color: mat-color($foreground, divider);
}
}
5 changes: 3 additions & 2 deletions src/lib/select/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 */
Expand Down
13 changes: 13 additions & 0 deletions src/lib/select/select-header.ts
Original file line number Diff line number Diff line change
@@ -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 { }
8 changes: 7 additions & 1 deletion src/lib/select/select.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@
<div class="mat-select-panel" [@transformPanel]="'showing'" (@transformPanel.done)="_onPanelDone()"
(keydown)="_keyManager.onKeydown($event)" [style.transformOrigin]="_transformOrigin"
[class.mat-select-panel-done-animating]="_panelDoneAnimating">
<div class="mat-select-content" [@fadeInContent]="'showing'" (@fadeInContent.done)="_onFadeInDone()">

<div [@fadeInContent]="'showing'">
<ng-content select="md-select-header, mat-select-header"></ng-content>
</div>

<div class="mat-select-content" [attr.id]="panelId" [@fadeInContent]="'showing'"
(@fadeInContent.done)="_onFadeInDone()">
<ng-content></ng-content>
</div>
</div>
Expand Down
26 changes: 22 additions & 4 deletions src/lib/select/select.scss
Original file line number Diff line number Diff line change
Expand Up @@ -119,14 +119,32 @@ $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 {
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;
}
}
114 changes: 104 additions & 10 deletions src/lib/select/select.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ describe('MdSelect', () => {
CompWithCustomSelect,
SelectWithErrorSibling,
ThrowsErrorOnInit,
BasicSelectOnPush
BasicSelectOnPush,
BasicSelectWithHeader,
],
providers: [
{provide: OverlayContainer, useFactory: () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -648,19 +660,23 @@ describe('MdSelect', () => {
let fixture: ComponentFixture<BasicSelect>;
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;
Expand All @@ -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}.`);
}
Expand All @@ -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.`);
Expand All @@ -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.`);
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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).
Expand All @@ -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)
Expand All @@ -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.`);
Expand All @@ -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.`);
Expand Down Expand Up @@ -1009,6 +1025,52 @@ describe('MdSelect', () => {
});
});

describe('with header', () => {
let headerFixture: ComponentFixture<BasicSelectWithHeader>;

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', () => {
Expand Down Expand Up @@ -1529,6 +1591,38 @@ class BasicSelectOnPush {
@ViewChildren(MdOption) options: QueryList<MdOption>;
}

@Component({
selector: 'basic-select-with-header',
template: `
<md-select placeholder="Food" [formControl]="control">
<md-select-header>
<input placeholder="Search for food" />
</md-select-header>
<md-option *ngFor="let food of foods" [value]="food.value">
{{ food.viewValue }}
</md-option>
</md-select>
`
})
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<MdOption>;
}


/**
* TODO: Move this to core testing utility until Angular has event faking
Expand Down
Loading

0 comments on commit 37b8056

Please sign in to comment.