Skip to content

Commit ddd36d7

Browse files
EernieErwin Oldenkamp
authored and
Erwin Oldenkamp
committed
feat(select): Add search support to the material2 select
1 parent 55b9224 commit ddd36d7

File tree

7 files changed

+166
-6
lines changed

7 files changed

+166
-6
lines changed

src/demo-app/select/select-demo.html

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,26 @@
3333
<button md-button (click)="drinkControl.reset()">RESET</button>
3434
</md-card>
3535

36+
<div>
37+
<md-card>
38+
<md-card-subtitle>Filter selection</md-card-subtitle>
39+
40+
<md-card-content>
41+
<md-select placeholder="Bond movies" [search]="true" [required]="movieRequired" [disabled]="moviesDisabled" [(ngModel)]="currentMovie" #movieControl="ngModel">
42+
<md-option *ngFor="let movie of movies" [value]="movie.value">{{ movie.viewValue }}</md-option>
43+
</md-select>
44+
<p> Value: {{ currentMovie }} </p>
45+
<p> Touched: {{ movieControl.touched }} </p>
46+
<p> Dirty: {{ movieControl.dirty }} </p>
47+
<p> Status: {{ movieControl.control?.status }} </p>
48+
<button md-button (click)="currentMovie='moonraker-0'">SET VALUE</button>
49+
<button md-button (click)="movieRequired=!movieRequired">TOGGLE REQUIRED</button>
50+
<button md-button (click)="moviesDisabled=!moviesDisabled">TOGGLE DISABLED</button>
51+
<button md-button (click)="movieControl.reset()">RESET</button>
52+
</md-card-content>
53+
</md-card>
54+
</div>
55+
3656
<div *ngIf="showSelect">
3757
<md-card>
3858
<md-select placeholder="Starter Pokemon" (change)="latestChangeEvent = $event">

src/demo-app/select/select-demo.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,12 @@ import {MdSelectChange} from '@angular/material';
1010
})
1111
export class SelectDemo {
1212
isRequired = false;
13+
movieRequired = false;
1314
isDisabled = false;
15+
moviesDisabled = false;
1416
showSelect = false;
1517
currentDrink: string;
18+
currentMovie: string;
1619
latestChangeEvent: MdSelectChange;
1720
foodControl = new FormControl('pizza-1');
1821

@@ -40,6 +43,17 @@ export class SelectDemo {
4043
{value: 'squirtle-2', viewValue: 'Squirtle'}
4144
];
4245

46+
movies = [
47+
{value: 'moonraker-0', viewValue: 'Moonraker'},
48+
{value: 'goldfinger-1', viewValue: 'Sprite'},
49+
{value: 'thunderball-2', viewValue: 'Water'},
50+
{value: 'dr-no-3', viewValue: 'Dr. No'},
51+
{value: 'octopussy-4', viewValue: 'Octopussy'},
52+
{value: 'goldeneye-5', viewValue: 'Goldeneye'},
53+
{value: 'skyfall-6', viewValue: 'Skyfall'},
54+
{value: 'spectre-7', viewValue: 'Spectre'}
55+
];
56+
4357
toggleDisabled() {
4458
this.foodControl.enabled ? this.foodControl.disable() : this.foodControl.enable();
4559
}

src/lib/select/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ import {
66
CompatibilityModule,
77
OverlayModule,
88
} from '../core';
9+
import {MdInputModule} from '../input/input';
10+
import {FormsModule} from '@angular/forms';
911
export * from './select';
1012
export {fadeInContent, transformPanel, transformPlaceholder} from './select-animations';
1113

1214

1315
@NgModule({
14-
imports: [CommonModule, OverlayModule, MdOptionModule, CompatibilityModule],
16+
imports: [CommonModule, OverlayModule, MdOptionModule, CompatibilityModule, MdInputModule, FormsModule],
1517
exports: [MdSelect, MdOptionModule, CompatibilityModule],
1618
declarations: [MdSelect],
1719
})

src/lib/select/select.html

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@
66
</div>
77

88
<template cdk-connected-overlay [origin]="origin" [open]="panelOpen" hasBackdrop (backdropClick)="close()"
9-
backdropClass="cdk-overlay-transparent-backdrop" [positions]="_positions" [minWidth]="_triggerWidth"
10-
[offsetY]="_offsetY" [offsetX]="_offsetX" (attach)="_setScrollTop()">
9+
backdropClass="cdk-overlay-transparent-backdrop" [positions]="_positions" [minWidth]="_triggerWidth"
10+
[offsetY]="_offsetY" [offsetX]="_offsetX" (attach)="_setScrollTop()">
1111
<div class="md-select-panel" [@transformPanel]="'showing'" (@transformPanel.done)="_onPanelDone()"
12-
(keydown)="_keyManager.onKeydown($event)" [style.transformOrigin]="_transformOrigin"
13-
[class.md-select-panel-done-animating]="_panelDoneAnimating">
12+
(keydown)="_keyManager.onKeydown($event)" [style.transformOrigin]="_transformOrigin"
13+
[class.md-select-panel-done-animating]="_panelDoneAnimating">
1414
<div class="md-select-content" [@fadeInContent]="'showing'" (@fadeInContent.done)="_onFadeInDone()">
15+
<md-input *ngIf="_search" type="text" [ngModel]="filter" (ngModelChange)="_onSearch($event)" [autocomplete]="false"></md-input>
1516
<ng-content></ng-content>
1617
</div>
1718
</div>

src/lib/select/select.scss

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,19 @@ md-select {
108108
}
109109
}
110110

111+
.md-select-content md-input {
112+
@include md-menu-item-base();
113+
height: auto;
114+
115+
.md-input-wrapper {
116+
width: 100%;
117+
}
118+
119+
.md-input-underline {
120+
width: calc(100% - #{$md-menu-side-padding*2})
121+
}
122+
}
123+
124+
md-option[hidden] {
125+
display: none;
126+
}

src/lib/select/select.spec.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
ControlValueAccessor, FormControl, FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule
1111
} from '@angular/forms';
1212
import {ViewportRuler} from '../core/overlay/position/viewport-ruler';
13+
import {MdInput} from '../input/input';
1314

1415
describe('MdSelect', () => {
1516
let overlayContainerElement: HTMLElement;
@@ -20,6 +21,7 @@ describe('MdSelect', () => {
2021
imports: [MdSelectModule.forRoot(), ReactiveFormsModule, FormsModule],
2122
declarations: [
2223
BasicSelect,
24+
SearchSelect,
2325
NgModelSelect,
2426
ManySelects,
2527
NgIfSelect,
@@ -1255,6 +1257,56 @@ describe('MdSelect', () => {
12551257
expect(fixture.componentInstance.changeListener).toHaveBeenCalledTimes(1);
12561258
});
12571259
});
1260+
1261+
describe('Search input', () => {
1262+
let fixture: ComponentFixture<SearchSelect>;
1263+
1264+
beforeEach(() => {
1265+
fixture = TestBed.createComponent(SearchSelect);
1266+
fixture.detectChanges();
1267+
1268+
let trigger = fixture.debugElement.query(By.css('.md-select-trigger')).nativeElement;
1269+
trigger.click();
1270+
fixture.detectChanges();
1271+
});
1272+
1273+
it('should hide elements that are not in the filter', () => {
1274+
fixture.componentInstance.select._onSearch('steak');
1275+
fixture.detectChanges();
1276+
1277+
fixture.whenStable().then(() =>
1278+
{
1279+
let options = overlayContainerElement.querySelectorAll('md-option') as NodeListOf<HTMLElement>;
1280+
1281+
expect(options[0].getAttribute('hidden')).toEqual(null);
1282+
expect(options[1].getAttribute('hidden')).toEqual('true');
1283+
expect(options[2].getAttribute('hidden')).toEqual('true');
1284+
expect(options[3].getAttribute('hidden')).toEqual('true');
1285+
expect(options[4].getAttribute('hidden')).toEqual('true');
1286+
expect(options[5].getAttribute('hidden')).toEqual('true');
1287+
expect(options[6].getAttribute('hidden')).toEqual('true');
1288+
expect(options[7].getAttribute('hidden')).toEqual('true');
1289+
});
1290+
});
1291+
1292+
it('should should not be case sensitive', () => {
1293+
fixture.componentInstance.select._onSearch('S-');
1294+
fixture.detectChanges();
1295+
1296+
fixture.whenStable().then(() => {
1297+
let options = overlayContainerElement.querySelectorAll('md-option') as NodeListOf<HTMLElement>;
1298+
1299+
expect(options[0].getAttribute('hidden')).toEqual('true');
1300+
expect(options[1].getAttribute('hidden')).toEqual('true');
1301+
expect(options[2].getAttribute('hidden')).toEqual(null);
1302+
expect(options[3].getAttribute('hidden')).toEqual('true');
1303+
expect(options[4].getAttribute('hidden')).toEqual(null);
1304+
expect(options[5].getAttribute('hidden')).toEqual(null);
1305+
expect(options[6].getAttribute('hidden')).toEqual('true');
1306+
expect(options[7].getAttribute('hidden')).toEqual('true');
1307+
});
1308+
});
1309+
});
12581310
});
12591311

12601312
@Component({
@@ -1289,6 +1341,38 @@ class BasicSelect {
12891341
@ViewChildren(MdOption) options: QueryList<MdOption>;
12901342
}
12911343

1344+
@Component({
1345+
selector: 'search-select',
1346+
template: `
1347+
<div [style.height.px]="heightAbove"></div>
1348+
<md-select placeholder="Food" [search]="true" [formControl]="control" [required]="isRequired">
1349+
<md-option *ngFor="let food of foods" [value]="food.value" [disabled]="food.disabled">
1350+
{{ food.viewValue }}
1351+
</md-option>
1352+
</md-select>
1353+
<div [style.height.px]="heightBelow"></div>
1354+
`
1355+
})
1356+
class SearchSelect {
1357+
foods: any[] = [
1358+
{ value: 'steak-0', viewValue: 'Steak' },
1359+
{ value: 'pizza-1', viewValue: 'Pizza' },
1360+
{ value: 'tacos-2', viewValue: 'Tacos', disabled: true },
1361+
{ value: 'sandwich-3', viewValue: 'Sandwich' },
1362+
{ value: 'chips-4', viewValue: 'Chips' },
1363+
{ value: 'eggs-5', viewValue: 'Eggs' },
1364+
{ value: 'pasta-6', viewValue: 'Pasta' },
1365+
{ value: 'sushi-7', viewValue: 'Sushi' },
1366+
];
1367+
control = new FormControl();
1368+
isRequired: boolean;
1369+
heightAbove = 0;
1370+
heightBelow = 0;
1371+
1372+
@ViewChild(MdSelect) select: MdSelect;
1373+
@ViewChildren(MdOption) options: QueryList<MdOption>;
1374+
}
1375+
12921376
@Component({
12931377
selector: 'ng-model-select',
12941378
template: `

src/lib/select/select.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,14 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
222222
get required() { return this._required; }
223223
set required(value: any) { this._required = coerceBooleanProperty(value); }
224224

225+
/** An optional search function. */
226+
@Input()
227+
get search() { return this._search; }
228+
set search(search: boolean) {
229+
this._search = search;
230+
}
231+
private _search: boolean;
232+
225233
/** Event emitted when the select has been opened. */
226234
@Output() onOpen: EventEmitter<void> = new EventEmitter<void>();
227235

@@ -272,6 +280,9 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
272280
this._calculateOverlayPosition();
273281
this._placeholderState = this._isRtl() ? 'floating-rtl' : 'floating-ltr';
274282
this._panelOpen = true;
283+
if (this._search) {
284+
this._onSearch('');
285+
}
275286
}
276287

277288
/** Closes the overlay panel and focuses the host element. */
@@ -410,6 +421,18 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
410421
scrollContainer.scrollTop = this._scrollTop;
411422
}
412423

424+
_onSearch(query:string) {
425+
this.options.forEach((option:MdOption) => {
426+
let valueIndex = option.value.toLowerCase().indexOf(query.toLowerCase());
427+
let viewValueIndex = option.viewValue.toLowerCase().indexOf(query.toLowerCase());
428+
if (valueIndex === -1 && viewValueIndex === -1) {
429+
option._getHostElement().setAttribute('hidden', 'true');
430+
} else {
431+
option._getHostElement().removeAttribute('hidden');
432+
}
433+
});
434+
}
435+
413436
/**
414437
* Sets the selected option based on a value. If no option can be
415438
* found with the designated value, the select trigger is cleared.
@@ -546,7 +569,7 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
546569
// The farthest the panel can be scrolled before it hits the bottom
547570
const maxScroll = scrollContainerHeight - panelHeight;
548571

549-
if (this.selected) {
572+
if (this.selected && !this._search) {
550573
const selectedIndex = this._getOptionIndex(this.selected);
551574
// We must maintain a scroll buffer so the selected option will be scrolled to the
552575
// center of the overlay panel rather than the top.

0 commit comments

Comments
 (0)