Skip to content

feat(select): Add search support to the material2 select #2797

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/demo-app/select/select-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,26 @@
<button md-button (click)="drinkControl.reset()">RESET</button>
</md-card>

<div>
<md-card>
<md-card-subtitle>Filter selection</md-card-subtitle>

<md-card-content>
<md-select placeholder="Bond movies" [search]="true" [required]="movieRequired" [disabled]="moviesDisabled" [(ngModel)]="currentMovie" #movieControl="ngModel">
<md-option *ngFor="let movie of movies" [value]="movie.value">{{ movie.viewValue }}</md-option>
</md-select>
<p> Value: {{ currentMovie }} </p>
<p> Touched: {{ movieControl.touched }} </p>
<p> Dirty: {{ movieControl.dirty }} </p>
<p> Status: {{ movieControl.control?.status }} </p>
<button md-button (click)="currentMovie='moonraker-0'">SET VALUE</button>
<button md-button (click)="movieRequired=!movieRequired">TOGGLE REQUIRED</button>
<button md-button (click)="moviesDisabled=!moviesDisabled">TOGGLE DISABLED</button>
<button md-button (click)="movieControl.reset()">RESET</button>
</md-card-content>
</md-card>
</div>

<div *ngIf="showSelect">
<md-card>
<md-select placeholder="Starter Pokemon" (change)="latestChangeEvent = $event">
Expand Down
14 changes: 14 additions & 0 deletions src/demo-app/select/select-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@ import {MdSelectChange} from '@angular/material';
})
export class SelectDemo {
isRequired = false;
movieRequired = false;
isDisabled = false;
moviesDisabled = false;
showSelect = false;
currentDrink: string;
currentMovie: string;
latestChangeEvent: MdSelectChange;
foodControl = new FormControl('pizza-1');

Expand Down Expand Up @@ -40,6 +43,17 @@ export class SelectDemo {
{value: 'squirtle-2', viewValue: 'Squirtle'}
];

movies = [
{value: 'moonraker-0', viewValue: 'Moonraker'},
{value: 'goldfinger-1', viewValue: 'Sprite'},
{value: 'thunderball-2', viewValue: 'Water'},
{value: 'dr-no-3', viewValue: 'Dr. No'},
{value: 'octopussy-4', viewValue: 'Octopussy'},
{value: 'goldeneye-5', viewValue: 'Goldeneye'},
{value: 'skyfall-6', viewValue: 'Skyfall'},
{value: 'spectre-7', viewValue: 'Spectre'}
];

toggleDisabled() {
this.foodControl.enabled ? this.foodControl.disable() : this.foodControl.enable();
}
Expand Down
11 changes: 10 additions & 1 deletion src/lib/select/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,21 @@ import {
CompatibilityModule,
OverlayModule,
} from '../core';
import {MdInputModule} from '../input/input';
import {FormsModule} from '@angular/forms';
export * from './select';
export {fadeInContent, transformPanel, transformPlaceholder} from './select-animations';


@NgModule({
imports: [CommonModule, OverlayModule, MdOptionModule, CompatibilityModule],
imports: [
CommonModule,
OverlayModule,
MdOptionModule,
CompatibilityModule,
MdInputModule,
FormsModule
],
exports: [MdSelect, MdOptionModule, CompatibilityModule],
declarations: [MdSelect],
})
Expand Down
9 changes: 5 additions & 4 deletions src/lib/select/select.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@
</div>

<template cdk-connected-overlay [origin]="origin" [open]="panelOpen" hasBackdrop (backdropClick)="close()"
backdropClass="cdk-overlay-transparent-backdrop" [positions]="_positions" [minWidth]="_triggerWidth"
[offsetY]="_offsetY" [offsetX]="_offsetX" (attach)="_setScrollTop()">
backdropClass="cdk-overlay-transparent-backdrop" [positions]="_positions" [minWidth]="_triggerWidth"
[offsetY]="_offsetY" [offsetX]="_offsetX" (attach)="_setScrollTop()">
<div class="md-select-panel" [@transformPanel]="'showing'" (@transformPanel.done)="_onPanelDone()"
(keydown)="_keyManager.onKeydown($event)" [style.transformOrigin]="_transformOrigin"
[class.md-select-panel-done-animating]="_panelDoneAnimating">
(keydown)="_keyManager.onKeydown($event)" [style.transformOrigin]="_transformOrigin"
[class.md-select-panel-done-animating]="_panelDoneAnimating">
<div class="md-select-content" [@fadeInContent]="'showing'" (@fadeInContent.done)="_onFadeInDone()">
<md-input *ngIf="_search" type="text" [ngModel]="filter" (ngModelChange)="_onSearch($event)" [autocomplete]="false"></md-input>
<ng-content></ng-content>
</div>
</div>
Expand Down
16 changes: 16 additions & 0 deletions src/lib/select/select.scss
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,19 @@ md-select {
}
}

.md-select-content md-input {
@include md-menu-item-base();
height: auto;

.md-input-wrapper {
width: 100%;
}

.md-input-underline {
width: calc(100% - #{$md-menu-side-padding*2});
}
}

md-option[hidden] {
display: none;
}
84 changes: 84 additions & 0 deletions src/lib/select/select.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ describe('MdSelect', () => {
imports: [MdSelectModule.forRoot(), ReactiveFormsModule, FormsModule],
declarations: [
BasicSelect,
SearchSelect,
NgModelSelect,
ManySelects,
NgIfSelect,
Expand Down Expand Up @@ -1255,6 +1256,57 @@ describe('MdSelect', () => {
expect(fixture.componentInstance.changeListener).toHaveBeenCalledTimes(1);
});
});

describe('Search input', () => {
let fixture: ComponentFixture<SearchSelect>;

beforeEach(() => {
fixture = TestBed.createComponent(SearchSelect);
fixture.detectChanges();

let trigger = fixture.debugElement.query(By.css('.md-select-trigger')).nativeElement;
trigger.click();
fixture.detectChanges();
});

it('should hide elements that are not in the filter', () => {
fixture.componentInstance.select._onSearch('steak');
fixture.detectChanges();

fixture.whenStable().then(() => {
let options =
overlayContainerElement.querySelectorAll('md-option') as NodeListOf<HTMLElement>;

expect(options[0].getAttribute('hidden')).toEqual(null);
expect(options[1].getAttribute('hidden')).toEqual('true');
expect(options[2].getAttribute('hidden')).toEqual('true');
expect(options[3].getAttribute('hidden')).toEqual('true');
expect(options[4].getAttribute('hidden')).toEqual('true');
expect(options[5].getAttribute('hidden')).toEqual('true');
expect(options[6].getAttribute('hidden')).toEqual('true');
expect(options[7].getAttribute('hidden')).toEqual('true');
});
});

it('should should not be case sensitive', () => {
fixture.componentInstance.select._onSearch('S-');
fixture.detectChanges();

fixture.whenStable().then(() => {
let options =
overlayContainerElement.querySelectorAll('md-option') as NodeListOf<HTMLElement>;

expect(options[0].getAttribute('hidden')).toEqual('true');
expect(options[1].getAttribute('hidden')).toEqual('true');
expect(options[2].getAttribute('hidden')).toEqual(null);
expect(options[3].getAttribute('hidden')).toEqual('true');
expect(options[4].getAttribute('hidden')).toEqual(null);
expect(options[5].getAttribute('hidden')).toEqual(null);
expect(options[6].getAttribute('hidden')).toEqual('true');
expect(options[7].getAttribute('hidden')).toEqual('true');
});
});
});
});

@Component({
Expand Down Expand Up @@ -1289,6 +1341,38 @@ class BasicSelect {
@ViewChildren(MdOption) options: QueryList<MdOption>;
}

@Component({
selector: 'search-select',
template: `
<div [style.height.px]="heightAbove"></div>
<md-select placeholder="Food" [search]="true" [formControl]="control" [required]="isRequired">
<md-option *ngFor="let food of foods" [value]="food.value" [disabled]="food.disabled">
{{ food.viewValue }}
</md-option>
</md-select>
<div [style.height.px]="heightBelow"></div>
`
})
class SearchSelect {
foods: any[] = [
{ value: 'steak-0', viewValue: 'Steak' },
{ value: 'pizza-1', viewValue: 'Pizza' },
{ value: 'tacos-2', viewValue: 'Tacos', disabled: true },
{ 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;
heightAbove = 0;
heightBelow = 0;

@ViewChild(MdSelect) select: MdSelect;
@ViewChildren(MdOption) options: QueryList<MdOption>;
}

@Component({
selector: 'ng-model-select',
template: `
Expand Down
25 changes: 24 additions & 1 deletion src/lib/select/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,14 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
get required() { return this._required; }
set required(value: any) { this._required = coerceBooleanProperty(value); }

/** An optional search function. */
@Input()
get search() { return this._search; }
set search(search: boolean) {
this._search = search;
}
private _search: boolean;

/** Event emitted when the select has been opened. */
@Output() onOpen: EventEmitter<void> = new EventEmitter<void>();

Expand Down Expand Up @@ -272,6 +280,9 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
this._calculateOverlayPosition();
this._placeholderState = this._isRtl() ? 'floating-rtl' : 'floating-ltr';
this._panelOpen = true;
if (this._search) {
this._onSearch('');
}
}

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

_onSearch(query: string): void {
this.options.forEach((option: MdOption) => {
let valueIndex = option.value.toLowerCase().indexOf(query.toLowerCase());
let viewValueIndex = option.viewValue.toLowerCase().indexOf(query.toLowerCase());
if (valueIndex === -1 && viewValueIndex === -1) {
option._getHostElement().setAttribute('hidden', 'true');
} else {
option._getHostElement().removeAttribute('hidden');
}
});
}

/**
* Sets the selected option based on a value. If no option can be
* found with the designated value, the select trigger is cleared.
Expand Down Expand Up @@ -546,7 +569,7 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
// The farthest the panel can be scrolled before it hits the bottom
const maxScroll = scrollContainerHeight - panelHeight;

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