Skip to content

feat(select): add mat-select-header and mat-select-search component #8050

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
17 changes: 11 additions & 6 deletions src/cdk/a11y/list-key-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {RxChain, debounceTime, filter, map, doOperator} from '@angular/cdk/rxjs'
*/
export interface ListKeyManagerOption {
disabled?: boolean;
excluded?: boolean;
getLabel?(): string;
}

Expand Down Expand Up @@ -82,7 +83,11 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
const index = (this._activeItemIndex + i) % items.length;
const item = items[index];

if (!item.disabled && item.getLabel!().toUpperCase().trim().indexOf(inputString) === 0) {
if (
!item.disabled &&
!item.excluded &&
item.getLabel!().toUpperCase().trim().indexOf(inputString) === 0
) {
this.setActiveItem(index);
break;
}
Expand Down Expand Up @@ -184,16 +189,16 @@ export class ListKeyManager<T extends ListKeyManagerOption> {

/**
* Sets the active item properly given "wrap" mode. In other words, it will continue to move
* down the list until it finds an item that is not disabled, and it will wrap if it
* down the list until it finds an item that is not disabled or excluded, and it will wrap if it
* encounters either end of the list.
*/
private _setActiveInWrapMode(delta: number, items: T[]): void {
// when active item would leave menu, wrap to beginning or end
this._activeItemIndex =
(this._activeItemIndex + delta + items.length) % items.length;

// skip all disabled menu items recursively until an enabled one is reached
if (items[this._activeItemIndex].disabled) {
// skip all disabled and excluded menu items recursively until an enabled one is reached
if (items[this._activeItemIndex].disabled || items[this._activeItemIndex].excluded) {
this._setActiveInWrapMode(delta, items);
} else {
this.setActiveItem(this._activeItemIndex);
Expand All @@ -211,13 +216,13 @@ export class ListKeyManager<T extends ListKeyManagerOption> {

/**
* Sets the active item to the first enabled item starting at the index specified. If the
* item is disabled, it will move in the fallbackDelta direction until it either
* item is disabled or excluded, it will move in the fallbackDelta direction until it either
* finds an enabled item or encounters the end of the list.
*/
private _setActiveItemByIndex(index: number, fallbackDelta: number,
items = this._items.toArray()): void {
if (!items[index]) { return; }
while (items[index].disabled) {
while (items[index].disabled || items[index].excluded) {
index += fallbackDelta;
if (!items[index]) { return; }
}
Expand Down
81 changes: 66 additions & 15 deletions src/demo-app/select/select-demo.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
Space above cards: <input type="number" [formControl]="topHeightCtrl">
Space above cards:
<input type="number" [formControl]="topHeightCtrl">
<button mat-button (click)="showSelect=!showSelect">SHOW SELECT</button>
<div [style.height.px]="topHeightCtrl.value"></div>

Expand All @@ -7,8 +8,7 @@
<mat-card-subtitle>ngModel</mat-card-subtitle>

<mat-form-field [floatPlaceholder]="floatPlaceholder" [color]="drinksTheme">
<mat-select placeholder="Drink" [(ngModel)]="currentDrink" [required]="drinksRequired"
[disabled]="drinksDisabled" #drinkControl="ngModel">
<mat-select placeholder="Drink" [(ngModel)]="currentDrink" [required]="drinksRequired" [disabled]="drinksDisabled" #drinkControl="ngModel">
<mat-option>None</mat-option>
<mat-option *ngFor="let drink of drinks" [value]="drink.value" [disabled]="drink.disabled">
{{ drink.viewValue }}
Expand Down Expand Up @@ -50,8 +50,8 @@

<mat-card-content>
<mat-form-field [color]="pokemonTheme">
<mat-select multiple placeholder="Pokemon" [(ngModel)]="currentPokemon"
[required]="pokemonRequired" [disabled]="pokemonDisabled" #pokemonControl="ngModel">
<mat-select multiple placeholder="Pokemon" [(ngModel)]="currentPokemon" [required]="pokemonRequired" [disabled]="pokemonDisabled"
#pokemonControl="ngModel">
<mat-option *ngFor="let creature of pokemon" [value]="creature.value">
{{ creature.viewValue }}
</mat-option>
Expand Down Expand Up @@ -98,8 +98,7 @@
<mat-card-content>
<mat-form-field>
<mat-select placeholder="Pokemon" [(ngModel)]="currentPokemonFromGroup">
<mat-optgroup *ngFor="let group of pokemonGroups" [label]="group.name"
[disabled]="group.disabled">
<mat-optgroup *ngFor="let group of pokemonGroups" [label]="group.name" [disabled]="group.disabled">
<mat-option *ngFor="let creature of group.pokemon" [value]="creature.value">
{{ creature.viewValue }}
</mat-option>
Expand All @@ -114,11 +113,8 @@
<mat-card-subtitle>compareWith</mat-card-subtitle>
<mat-card-content>
<mat-form-field [color]="drinksTheme">
<mat-select placeholder="Drink"
[(ngModel)]="currentDrinkObject"
[required]="drinkObjectRequired"
[compareWith]="compareByValue ? compareDrinkObjectsByValue : compareByReference"
#drinkObjectControl="ngModel">
<mat-select placeholder="Drink" [(ngModel)]="currentDrinkObject" [required]="drinkObjectRequired" [compareWith]="compareByValue ? compareDrinkObjectsByValue : compareByReference"
#drinkObjectControl="ngModel">
<mat-option *ngFor="let drink of drinks" [value]="drink" [disabled]="drink.disabled">
{{ drink.viewValue }}
</mat-option>
Expand All @@ -130,8 +126,7 @@
<p> Status: {{ drinkObjectControl.control?.status }} </p>
<p> Comparison Mode: {{ compareByValue ? 'VALUE' : 'REFERENCE' }} </p>

<button mat-button (click)="reassignDrinkByCopy()"
matTooltip="This action should clear the display value when comparing by reference.">
<button mat-button (click)="reassignDrinkByCopy()" matTooltip="This action should clear the display value when comparing by reference.">
REASSIGN DRINK BY COPY
</button>
<button mat-button (click)="drinkObjectRequired=!drinkObjectRequired">TOGGLE REQUIRED</button>
Expand All @@ -140,6 +135,62 @@
</mat-card-content>
</mat-card>

<mat-card>
<mat-card-subtitle>Select Header (with Search)</mat-card-subtitle>
<mat-card-content>
<p>Single Selection with header</p>
<mat-form-field>
<mat-select placeholder="Drink" [(ngModel)]="currentDrink">
<mat-select-header class="demo-select-header-auto">
<mat-checkbox [(ngModel)]="withPizza" name="pizza_cb">with pizza please!</mat-checkbox>
</mat-select-header>
<mat-option *ngFor="let drink of drinks" [value]="drink.value" [disabled]="drink.disabled">
{{ drink.viewValue }}
</mat-option>
</mat-select>
</mat-form-field>
<span [hidden]="!withPizza">with pizza</span>

<p>Single Selection with search</p>
<mat-form-field>
<mat-select placeholder="Drink" [(ngModel)]="currentDrink">
<mat-select-header>
<mat-select-search placeholder="Type to search your drink.."></mat-select-search>
</mat-select-header>
<mat-option *ngFor="let drink of drinks" [value]="drink.value" [disabled]="drink.disabled">
{{ drink.viewValue }}
</mat-option>
</mat-select>
</mat-form-field>

<p>Multiple Selection with "starts with" custom search</p>
<mat-form-field [color]="pokemonTheme">
<mat-select multiple placeholder="Pokemon" [(ngModel)]="currentPokemon" [required]="pokemonRequired" [disabled]="pokemonDisabled"
#pokemonControl="ngModel">
<mat-select-header>
<mat-select-search [filterMatchFactory]="startsWithFilter" placeholder="Type to search your pokemon.."></mat-select-search>
</mat-select-header>
<mat-option *ngFor="let creature of pokemon" [value]="creature.value">
{{ creature.viewValue }}
</mat-option>
</mat-select>
</mat-form-field>

<p>Remote Search Multiple Selection</p>
<mat-form-field [color]="pokemonTheme">
<mat-select multiple placeholder="Pokemon" [(ngModel)]="currentAsyncPokemons" [required]="pokemonRequired" [disabled]="pokemonDisabled"
#pokemonControl="ngModel">
<mat-select-header>
<mat-select-search (onSearch)="searchRemotePokemons($event, currentAsyncPokemons)" [remoteSearch]="true" placeholder="Type to search your pokemon.."></mat-select-search>
</mat-select-header>
<mat-option *ngFor="let creature of remotePokemons | async" [value]="creature.value">
{{ creature.viewValue }}
</mat-option>
</mat-select>
</mat-form-field>
</mat-card-content>
</mat-card>

<div *ngIf="showSelect">
<mat-card>
<mat-card-subtitle>formControl</mat-card-subtitle>
Expand Down Expand Up @@ -178,4 +229,4 @@
</div>

</div>
<div style="height: 500px">This div is for testing scrolled selects.</div>
<div style="height: 500px">This div is for testing scrolled selects.</div>
8 changes: 8 additions & 0 deletions src/demo-app/select/select-demo.scss
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,11 @@
padding-right: 0.25em;
}
}

.demo-drink-header {
/*color: #3f51b5;*/
}

.mat-select-header.demo-select-header-auto {
height: auto;
}
92 changes: 59 additions & 33 deletions src/demo-app/select/select-demo.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import {Component} from '@angular/core';
import {FormControl} from '@angular/forms';
import {MatSelectChange} from '@angular/material';
import { Component } from '@angular/core';
import { FormControl } from '@angular/forms';
import { MatSelectChange } from '@angular/material';
import { Observable } from 'rxjs/Observable';
import { Subscription } from 'rxjs/Subscription';
import { Subject } from 'rxjs/Subject';

@Component({
moduleId: module.id,
selector: 'select-demo',
templateUrl: 'select-demo.html',
styleUrls: ['select-demo.css'],
moduleId: module.id,
selector: 'select-demo',
templateUrl: 'select-demo.html',
styleUrls: ['select-demo.css'],
})
export class SelectDemo {
drinksRequired = false;
Expand All @@ -15,9 +18,11 @@ export class SelectDemo {
drinksDisabled = false;
pokemonDisabled = false;
showSelect = false;
withPizza = false;
currentDrink: string;
currentDrinkObject: {}|undefined = {value: 'tea-5', viewValue: 'Tea'};
currentDrinkObject: {} | undefined = { value: 'tea-5', viewValue: 'Tea' };
currentPokemon: string[];
currentAsyncPokemons: string[];
currentPokemonFromGroup: string;
currentDigimon: string;
latestChangeEvent: MatSelectChange;
Expand All @@ -29,38 +34,38 @@ export class SelectDemo {
compareByValue = true;

foods = [
{value: null, viewValue: 'None'},
{value: 'steak-0', viewValue: 'Steak'},
{value: 'pizza-1', viewValue: 'Pizza'},
{value: 'tacos-2', viewValue: 'Tacos'}
{ value: null, viewValue: 'None' },
{ value: 'steak-0', viewValue: 'Steak' },
{ value: 'pizza-1', viewValue: 'Pizza' },
{ value: 'tacos-2', viewValue: 'Tacos' }
];

drinks = [
{value: 'coke-0', viewValue: 'Coke'},
{value: 'long-name-1', viewValue: 'Decaf Chocolate Brownie Vanilla Gingerbread Frappuccino'},
{value: 'water-2', viewValue: 'Water'},
{value: 'pepper-3', viewValue: 'Dr. Pepper'},
{value: 'coffee-4', viewValue: 'Coffee'},
{value: 'tea-5', viewValue: 'Tea'},
{value: 'juice-6', viewValue: 'Orange juice'},
{value: 'wine-7', viewValue: 'Wine'},
{value: 'milk-8', viewValue: 'Milk'},
{ value: 'coke-0', viewValue: 'Coke' },
{ value: 'long-name-1', viewValue: 'Decaf Chocolate Brownie Vanilla Gingerbread Frappuccino' },
{ value: 'water-2', viewValue: 'Water' },
{ value: 'pepper-3', viewValue: 'Dr. Pepper' },
{ value: 'coffee-4', viewValue: 'Coffee' },
{ value: 'tea-5', viewValue: 'Tea' },
{ value: 'juice-6', viewValue: 'Orange juice' },
{ value: 'wine-7', viewValue: 'Wine' },
{ value: 'milk-8', viewValue: 'Milk' },
];

pokemon = [
{value: 'bulbasaur-0', viewValue: 'Bulbasaur'},
{value: 'charizard-1', viewValue: 'Charizard'},
{value: 'squirtle-2', viewValue: 'Squirtle'},
{value: 'pikachu-3', viewValue: 'Pikachu'},
{value: 'eevee-4', viewValue: 'Eevee'},
{value: 'ditto-5', viewValue: 'Ditto'},
{value: 'psyduck-6', viewValue: 'Psyduck'},
{ value: 'bulbasaur-0', viewValue: 'Bulbasaur' },
{ value: 'charizard-1', viewValue: 'Charizard' },
{ value: 'squirtle-2', viewValue: 'Squirtle' },
{ value: 'pikachu-3', viewValue: 'Pikachu' },
{ value: 'eevee-4', viewValue: 'Eevee' },
{ value: 'ditto-5', viewValue: 'Ditto' },
{ value: 'psyduck-6', viewValue: 'Psyduck' },
];

availableThemes = [
{value: 'primary', name: 'Primary' },
{value: 'accent', name: 'Accent' },
{value: 'warn', name: 'Warn' }
{ value: 'primary', name: 'Primary' },
{ value: 'accent', name: 'Accent' },
{ value: 'warn', name: 'Warn' }
];

pokemonGroups = [
Expand Down Expand Up @@ -116,14 +121,35 @@ export class SelectDemo {
}

reassignDrinkByCopy() {
this.currentDrinkObject = {...this.currentDrinkObject};
this.currentDrinkObject = { ...this.currentDrinkObject };
}

compareDrinkObjectsByValue(d1: {value: string}, d2: {value: string}) {
compareDrinkObjectsByValue(d1: { value: string }, d2: { value: string }) {
return d1 && d2 && d1.value === d2.value;
}

compareByReference(o1: any, o2: any) {
return o1 === o2;
}

remotePokemons: Subject<any> = new Subject<any>();

startsWithFilter(search: string) {
return (l: string) => {
return l.toLowerCase().indexOf(search.toLowerCase()) === 0;
};
}

searchRemotePokemons(search: string, selectedValues: string[]) {
setTimeout(() => {
this.remotePokemons.next(
this.pokemon.filter(p =>
(
search && p.viewValue.toLowerCase().startsWith(search.toLowerCase()) ||
selectedValues && selectedValues.indexOf(p.value) >= 0
)
)
);
}, 250);
}
}
4 changes: 4 additions & 0 deletions src/lib/core/option/_option.scss
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
}
}

.mat-option-excluded {
display: none;
}

// Collapses unwanted whitespace created by newlines in code like the following:
// <mat-option>
// {{value}}
Expand Down
19 changes: 19 additions & 0 deletions src/lib/core/option/option.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export class MatOptionSelectionChange {
export interface MatOptionParentComponent {
disableRipple?: boolean;
multiple?: boolean;
panelId?: string;
}

/**
Expand All @@ -68,6 +69,7 @@ export const MAT_OPTION_PARENT_COMPONENT =
'[attr.aria-selected]': 'selected.toString()',
'[attr.aria-disabled]': 'disabled.toString()',
'[class.mat-option-disabled]': 'disabled',
'[class.mat-option-excluded]': 'excluded',
'(click)': '_selectViaInteraction()',
'(keydown)': '_handleKeydown($event)',
'class': 'mat-option',
Expand All @@ -81,8 +83,14 @@ export class MatOption {
private _selected = false;
private _active = false;
private _disabled = false;
private _excluded = false;
private _id = `mat-option-${_uniqueIdCounter++}`;

/** Wether the option does not match the search filter */
get excluded(): boolean {
return this._excluded;
}

/** Whether the wrapping component is in multiple selection mode. */
get multiple() { return this._parent && this._parent.multiple; }

Expand Down Expand Up @@ -178,6 +186,17 @@ export class MatOption {
}
}

/**
* Sets excluded status and styles (used by select-search logic)
* @param excluded
*/
setExcludeStyles(excluded: boolean): void {
if (this._excluded != excluded) {
this._excluded = excluded;
this._changeDetectorRef.markForCheck();
}
}

/** Gets the label to be used when determining whether the option should be focused. */
getLabel(): string {
return this.viewValue;
Expand Down
Loading