From 5c196ad65d1bd5d8cb02a6bd78407ee2ef5be198 Mon Sep 17 00:00:00 2001 From: Alberto Aldegheri Date: Thu, 26 Oct 2017 19:34:48 +0200 Subject: [PATCH] feat(select): add mat-select-header and mat-select-search component Adds a `mat-select-header` component, which is a fixed header above the select's options. It allows for the user to put anything there: a custom text, or any focusable element (inputs, ...). **Note:** This component only handles the positioning, styling, some basic focus management and exposes the panel id for a11y. Adds a `mat-select-search` component, which can be placed inside `mat-select-header`. It renders a special input field that manages long list filtering automatically. **Note:** The user can use his own custom search function. This input can also be used to handle the options filtering from outside. Fixes #2812. --- src/cdk/a11y/list-key-manager.ts | 17 +- src/demo-app/select/select-demo.html | 81 ++- src/demo-app/select/select-demo.scss | 8 + src/demo-app/select/select-demo.ts | 92 +-- src/lib/core/option/_option.scss | 4 + src/lib/core/option/option.ts | 19 + src/lib/core/style/_menu-common.scss | 4 + src/lib/select/_select-theme.scss | 8 + src/lib/select/public-api.ts | 2 + src/lib/select/select-header.html | 3 + src/lib/select/select-header.ts | 33 ++ src/lib/select/select-module.ts | 16 +- src/lib/select/select-search.html | 10 + src/lib/select/select-search.ts | 154 +++++ src/lib/select/select.html | 14 +- src/lib/select/select.scss | 32 +- src/lib/select/select.spec.ts | 534 ++++++++++++------ src/lib/select/select.ts | 71 ++- .../select-header/select-header-example.css | 1 + .../select-header/select-header-example.html | 12 + .../select-header/select-header-example.ts | 22 + .../select-search/select-search-example.css | 1 + .../select-search/select-search-example.html | 11 + .../select-search/select-search-example.ts | 28 + 24 files changed, 920 insertions(+), 257 deletions(-) create mode 100644 src/lib/select/select-header.html create mode 100644 src/lib/select/select-header.ts create mode 100644 src/lib/select/select-search.html create mode 100644 src/lib/select/select-search.ts create mode 100644 src/material-examples/select-header/select-header-example.css create mode 100644 src/material-examples/select-header/select-header-example.html create mode 100644 src/material-examples/select-header/select-header-example.ts create mode 100644 src/material-examples/select-search/select-search-example.css create mode 100644 src/material-examples/select-search/select-search-example.html create mode 100644 src/material-examples/select-search/select-search-example.ts diff --git a/src/cdk/a11y/list-key-manager.ts b/src/cdk/a11y/list-key-manager.ts index af8e82711a56..89e9ef2f00dc 100644 --- a/src/cdk/a11y/list-key-manager.ts +++ b/src/cdk/a11y/list-key-manager.ts @@ -17,6 +17,7 @@ import {RxChain, debounceTime, filter, map, doOperator} from '@angular/cdk/rxjs' */ export interface ListKeyManagerOption { disabled?: boolean; + excluded?: boolean; getLabel?(): string; } @@ -82,7 +83,11 @@ export class ListKeyManager { 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; } @@ -184,7 +189,7 @@ export class ListKeyManager { /** * 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 { @@ -192,8 +197,8 @@ export class ListKeyManager { 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); @@ -211,13 +216,13 @@ export class ListKeyManager { /** * 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; } } diff --git a/src/demo-app/select/select-demo.html b/src/demo-app/select/select-demo.html index 1ce8ef756f15..18162a85f7ab 100644 --- a/src/demo-app/select/select-demo.html +++ b/src/demo-app/select/select-demo.html @@ -1,4 +1,5 @@ -Space above cards: +Space above cards: +
@@ -7,8 +8,7 @@ ngModel - + None {{ drink.viewValue }} @@ -50,8 +50,8 @@ - + {{ creature.viewValue }} @@ -98,8 +98,7 @@ - + {{ creature.viewValue }} @@ -114,11 +113,8 @@ compareWith - + {{ drink.viewValue }} @@ -130,8 +126,7 @@

Status: {{ drinkObjectControl.control?.status }}

Comparison Mode: {{ compareByValue ? 'VALUE' : 'REFERENCE' }}

- @@ -140,6 +135,62 @@
+ + Select Header (with Search) + +

Single Selection with header

+ + + + with pizza please! + + + {{ drink.viewValue }} + + + + with pizza + +

Single Selection with search

+ + + + + + + {{ drink.viewValue }} + + + + +

Multiple Selection with "starts with" custom search

+ + + + + + + {{ creature.viewValue }} + + + + +

Remote Search Multiple Selection

+ + + + + + + {{ creature.viewValue }} + + + +
+
+
formControl @@ -178,4 +229,4 @@
-
This div is for testing scrolled selects.
+
This div is for testing scrolled selects.
\ No newline at end of file diff --git a/src/demo-app/select/select-demo.scss b/src/demo-app/select/select-demo.scss index f87b50cbe96b..487144bf6ccc 100644 --- a/src/demo-app/select/select-demo.scss +++ b/src/demo-app/select/select-demo.scss @@ -12,3 +12,11 @@ padding-right: 0.25em; } } + +.demo-drink-header { + /*color: #3f51b5;*/ +} + +.mat-select-header.demo-select-header-auto { + height: auto; +} \ No newline at end of file diff --git a/src/demo-app/select/select-demo.ts b/src/demo-app/select/select-demo.ts index 1967e60f7c43..8818550b031b 100644 --- a/src/demo-app/select/select-demo.ts +++ b/src/demo-app/select/select-demo.ts @@ -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; @@ -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; @@ -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 = [ @@ -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 = new Subject(); + + 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); + } } diff --git a/src/lib/core/option/_option.scss b/src/lib/core/option/_option.scss index c87b65e598d5..51e1d334b3bd 100644 --- a/src/lib/core/option/_option.scss +++ b/src/lib/core/option/_option.scss @@ -29,6 +29,10 @@ } } + .mat-option-excluded { + display: none; + } + // Collapses unwanted whitespace created by newlines in code like the following: // // {{value}} diff --git a/src/lib/core/option/option.ts b/src/lib/core/option/option.ts index 27bb5bfa7c00..2a396e6a545d 100644 --- a/src/lib/core/option/option.ts +++ b/src/lib/core/option/option.ts @@ -43,6 +43,7 @@ export class MatOptionSelectionChange { export interface MatOptionParentComponent { disableRipple?: boolean; multiple?: boolean; + panelId?: string; } /** @@ -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', @@ -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; } @@ -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; diff --git a/src/lib/core/style/_menu-common.scss b/src/lib/core/style/_menu-common.scss index 74800cfec21f..a459d88d6fd6 100644 --- a/src/lib/core/style/_menu-common.scss +++ b/src/lib/core/style/_menu-common.scss @@ -15,8 +15,12 @@ $mat-menu-icon-margin: 16px !default; @mixin mat-menu-base($default-elevation) { @include mat-overridable-elevation($default-elevation); + @include mat-menu-scrollable(); min-width: $mat-menu-overlay-min-width; max-width: $mat-menu-overlay-max-width; +} + +@mixin mat-menu-scrollable() { overflow: auto; -webkit-overflow-scrolling: touch; // for momentum scroll on mobile } diff --git a/src/lib/select/_select-theme.scss b/src/lib/select/_select-theme.scss index 685f8f2b82a1..555bd060f90b 100644 --- a/src/lib/select/_select-theme.scss +++ b/src/lib/select/_select-theme.scss @@ -31,6 +31,14 @@ } } + .mat-select-header { + border-color: mat-color($foreground, divider); + } + + .mat-select-header-input { + color: mat-color($foreground, text); + } + .mat-form-field { &.mat-focused { &.mat-primary .mat-select-arrow { diff --git a/src/lib/select/public-api.ts b/src/lib/select/public-api.ts index a89d7fe23e80..9a64389c0973 100644 --- a/src/lib/select/public-api.ts +++ b/src/lib/select/public-api.ts @@ -9,3 +9,5 @@ export * from './select-module'; export * from './select'; export * from './select-animations'; +export * from './select-header'; +export * from './select-search'; diff --git a/src/lib/select/select-header.html b/src/lib/select/select-header.html new file mode 100644 index 000000000000..460e0a5b8ad9 --- /dev/null +++ b/src/lib/select/select-header.html @@ -0,0 +1,3 @@ + + + diff --git a/src/lib/select/select-header.ts b/src/lib/select/select-header.ts new file mode 100644 index 000000000000..28a539007ebc --- /dev/null +++ b/src/lib/select/select-header.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Component, ViewEncapsulation, ChangeDetectionStrategy, ViewChild} from '@angular/core'; +import {FocusTrapDirective} from '@angular/cdk/a11y'; + +/** + * Fixed header that will be rendered above a select's options. + * Can be used as a bar for filtering out options. + */ +@Component({ + moduleId: module.id, + selector: 'mat-select-header', + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + preserveWhitespaces: false, + templateUrl: 'select-header.html', + host: { + 'class': 'mat-select-header', + } +}) +export class MatSelectHeader { + @ViewChild(FocusTrapDirective) _focusTrap: FocusTrapDirective; + + _trapFocus() { + this._focusTrap.focusTrap.focusFirstTabbableElementWhenReady(); + } +} diff --git a/src/lib/select/select-module.ts b/src/lib/select/select-module.ts index 3da48beca6bd..73d72346ac11 100644 --- a/src/lib/select/select-module.ts +++ b/src/lib/select/select-module.ts @@ -8,10 +8,13 @@ import {NgModule} from '@angular/core'; import {CommonModule} from '@angular/common'; import {MatSelect, MatSelectTrigger, MAT_SELECT_SCROLL_STRATEGY_PROVIDER} from './select'; +import {MatSelectHeader} from './select-header'; import {MatCommonModule, MatOptionModule} from '@angular/material/core'; import {OverlayModule} from '@angular/cdk/overlay'; import {MatFormFieldModule} from '@angular/material/form-field'; import {ErrorStateMatcher} from '@angular/material/core'; +import {A11yModule} from '@angular/cdk/a11y'; +import {MatSelectSearch} from './select-search'; @NgModule({ @@ -20,9 +23,18 @@ import {ErrorStateMatcher} from '@angular/material/core'; OverlayModule, MatOptionModule, MatCommonModule, + A11yModule, ], - exports: [MatFormFieldModule, MatSelect, MatSelectTrigger, MatOptionModule, MatCommonModule], - declarations: [MatSelect, MatSelectTrigger], + exports: [ + MatFormFieldModule, + MatSelect, + MatSelectTrigger, + MatSelectHeader, + MatSelectSearch, + MatOptionModule, + MatCommonModule, + ], + declarations: [MatSelect, MatSelectTrigger, MatSelectHeader, MatSelectSearch], providers: [MAT_SELECT_SCROLL_STRATEGY_PROVIDER, ErrorStateMatcher] }) export class MatSelectModule {} diff --git a/src/lib/select/select-search.html b/src/lib/select/select-search.html new file mode 100644 index 000000000000..afc26516e923 --- /dev/null +++ b/src/lib/select/select-search.html @@ -0,0 +1,10 @@ + \ No newline at end of file diff --git a/src/lib/select/select-search.ts b/src/lib/select/select-search.ts new file mode 100644 index 000000000000..47a478f984e1 --- /dev/null +++ b/src/lib/select/select-search.ts @@ -0,0 +1,154 @@ +import { Subject } from 'rxjs/Subject'; +import { QueryList } from '@angular/core'; +import { + MatOption, + MAT_OPTION_PARENT_COMPONENT, + MatOptionParentComponent +} from '../core/option/option'; +import { MatSelect } from './select'; +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + Component, + ViewEncapsulation, + ChangeDetectionStrategy, + Input, + ElementRef, + ViewChild, + OnInit, + Inject, + Renderer2, + EventEmitter, + Output, +} from '@angular/core'; + +/** A factory to create a match function to filter the options */ +export type FilterMatchFactory = (searchTerms: string) => (label: string) => boolean; + +/** + * Fixed header that will be rendered above a select's options. + * Can be used as a bar for filtering out options. + */ +@Component({ + moduleId: module.id, + selector: 'mat-select-search', + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + preserveWhitespaces: false, + templateUrl: 'select-search.html', + host: { + 'class': 'mat-select-search', + } +}) +export class MatSelectSearch { + /** + * A regexp to create another regular expression out of any possible string. + */ + private static readonly _ESCAPE_REGEX = /[\-\[\]{}()*+?.,\\\^$|#\s]/g; + + /** + * A regexp to find words in a string + */ + private static readonly _WORD_REGEX = /[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF\w]+/g; + + /** + * Default search function implementation + * @param searchTerms search string + */ + static DefaultMatchFactory(searchTerms: string): (label: string) => boolean { + if (!searchTerms) { + return () => true; + } + + // find words + let words = searchTerms.match(MatSelectSearch._WORD_REGEX); + if (!words || !words.length) { + return () => true; + } + + // escape every word + let i; + for (i = 0; i < words.length; i++) { + words[i] = words[i].replace(MatSelectSearch._ESCAPE_REGEX, '\\$&'); + } + // add escaped words to our new search regex + const matcher = new RegExp('^(?=.*' + words.join(')(?=.*') + ')', 'mgi'); + + return (l: string) => { const isMatch: boolean = matcher.test(l); return isMatch; }; + } + + /** + * Input placeholder + */ + @Input() placeholder: string; + + /** + * Remote search mode does cause no filter + */ + @Input() remoteSearch = false; + + /** + * A factory to create a match function to filter the options + */ + @Input() filterMatchFactory: FilterMatchFactory = MatSelectSearch.DefaultMatchFactory; + + /** + * Change event string that is emitted when the search string changes + */ + @Output() onSearch: EventEmitter = new EventEmitter(); + + /** + * Search input element + */ + @ViewChild('search') _searchInput: ElementRef; + + /** + * aria-owns panelId + */ + get panelId(): string | undefined { + return this._parent.panelId; + } + + /** + * Get focus status + */ + get focused(): boolean { + return this._focused; + } + + /** + * Observable search + */ + // get onSearch(): Subject { + // return this._onSearch; + // } + + private _focused = false; + + constructor( + private _renderer: Renderer2, + @Inject(MAT_OPTION_PARENT_COMPONENT) private _parent: MatOptionParentComponent + ) { } + + /** + * Resets the search string programmatically + */ + resetSearch(): void { + this._renderer.setProperty(this._searchInput.nativeElement, 'value', ''); + this._handleInput(''); + } + + _handleInput(value: string): void { + this.onSearch.next(value || ''); + } + + _handleFocus(value: boolean): void { + this._focused = value; + } +} diff --git a/src/lib/select/select.html b/src/lib/select/select.html index eb42a39aa6d9..f721f6ffb2de 100644 --- a/src/lib/select/select.html +++ b/src/lib/select/select.html @@ -34,20 +34,20 @@ (detach)="close()">
+ [style.font-size.px]="_triggerFontSize" + (keydown)="_handleKeydown($event)"> -
- +
+ +
+ +
diff --git a/src/lib/select/select.scss b/src/lib/select/select.scss index 7f7dd9af7f0e..02aea2615b40 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,34 @@ $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-width: 1px; + border-bottom-style: solid; + 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-search { 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 d840b3198d46..01963d284a87 100644 --- a/src/lib/select/select.spec.ts +++ b/src/lib/select/select.spec.ts @@ -1,8 +1,8 @@ -import {Directionality} from '@angular/cdk/bidi'; -import {DOWN_ARROW, END, ENTER, HOME, SPACE, TAB, UP_ARROW} from '@angular/cdk/keycodes'; -import {OverlayContainer} from '@angular/cdk/overlay'; -import {Platform} from '@angular/cdk/platform'; -import {ScrollDispatcher, ViewportRuler} from '@angular/cdk/scrolling'; +import { Directionality } from '@angular/cdk/bidi'; +import { DOWN_ARROW, END, ENTER, HOME, SPACE, TAB, UP_ARROW } from '@angular/cdk/keycodes'; +import { OverlayContainer } from '@angular/cdk/overlay'; +import { Platform } from '@angular/cdk/platform'; +import { ScrollDispatcher, ViewportRuler } from '@angular/cdk/scrolling'; import { dispatchFakeEvent, dispatchEvent, @@ -19,7 +19,7 @@ import { ViewChild, ViewChildren, } from '@angular/core'; -import {async, ComponentFixture, fakeAsync, inject, TestBed, tick} from '@angular/core/testing'; +import { async, ComponentFixture, fakeAsync, inject, TestBed, tick } from '@angular/core/testing'; import { ControlValueAccessor, FormControl, @@ -37,13 +37,14 @@ import { MatOption, ErrorStateMatcher, } from '@angular/material/core'; -import {MatFormFieldModule} from '@angular/material/form-field'; -import {By} from '@angular/platform-browser'; -import {NoopAnimationsModule} from '@angular/platform-browser/animations'; -import {map} from 'rxjs/operator/map'; -import {Subject} from 'rxjs/Subject'; -import {MatSelectModule} from './index'; -import {MatSelect} from './select'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { map } from 'rxjs/operator/map'; +import { Subject } from 'rxjs/Subject'; +import { MatSelectModule } from './index'; +import { MatSelect } from './select'; +import { MatSelectSearch } from './select-search'; import { getMatSelectDynamicMultipleError, getMatSelectNonArrayValueError, @@ -59,7 +60,7 @@ const SELECT_CLOSE_ANIMATION = 500; describe('MatSelect', () => { let overlayContainerElement: HTMLElement; - let dir: {value: 'ltr'|'rtl'}; + let dir: { value: 'ltr' | 'rtl' }; let scrolledSubject = new Subject(); let viewportRuler: ViewportRuler; @@ -106,24 +107,30 @@ describe('MatSelect', () => { NgModelCompareWithSelect, CustomErrorBehaviorSelect, SingleSelectWithPreselectedArrayValues, + BasicSelectWithHeader, + BasicSelectWithSearch, ], providers: [ - {provide: OverlayContainer, useFactory: () => { - overlayContainerElement = document.createElement('div') as HTMLElement; - overlayContainerElement.classList.add('cdk-overlay-container'); - - document.body.appendChild(overlayContainerElement); - - // remove body padding to keep consistent cross-browser - document.body.style.padding = '0'; - document.body.style.margin = '0'; - - return {getContainerElement: () => overlayContainerElement}; - }}, - {provide: Directionality, useFactory: () => dir = { value: 'ltr' }}, - {provide: ScrollDispatcher, useFactory: () => ({ - scrolled: () => scrolledSubject.asObservable() - })} + { + provide: OverlayContainer, useFactory: () => { + overlayContainerElement = document.createElement('div') as HTMLElement; + overlayContainerElement.classList.add('cdk-overlay-container'); + + document.body.appendChild(overlayContainerElement); + + // remove body padding to keep consistent cross-browser + document.body.style.padding = '0'; + document.body.style.margin = '0'; + + return { getContainerElement: () => overlayContainerElement }; + } + }, + { provide: Directionality, useFactory: () => dir = { value: 'ltr' } }, + { + provide: ScrollDispatcher, useFactory: () => ({ + scrolled: () => scrolledSubject.asObservable() + }) + } ] }).compileComponents(); })); @@ -198,7 +205,7 @@ describe('MatSelect', () => { tick(SELECT_OPEN_ANIMATION); const backdrop = - overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; + overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; backdrop.click(); fixture.detectChanges(); @@ -315,6 +322,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', () => { trigger.click(); fixture.detectChanges(); @@ -379,7 +397,7 @@ describe('MatSelect', () => { it('should not float placeholder if no option is selected', () => { expect(formField.classList.contains('mat-form-field-should-float')) - .toBe(false, 'placeholder should not be floating'); + .toBe(false, 'placeholder should not be floating'); }); it('should focus the first option if no option is selected', fakeAsync(() => { @@ -512,7 +530,7 @@ describe('MatSelect', () => { const value = fixture.debugElement.query(By.css('.mat-select-value')).nativeElement; expect(formField.classList.contains('mat-form-field-should-float')) - .toBe(true, 'placeholder should be floating'); + .toBe(true, 'placeholder should be floating'); expect(value.textContent).toContain('Steak'); })); @@ -533,7 +551,7 @@ describe('MatSelect', () => { })); it('should select an option that was added after initialization', fakeAsync(() => { - fixture.componentInstance.foods.push({viewValue: 'Potatoes', value: 'potatoes-8'}); + fixture.componentInstance.foods.push({ viewValue: 'Potatoes', value: 'potatoes-8' }); trigger.click(); fixture.detectChanges(); tick(SELECT_OPEN_ANIMATION); @@ -600,7 +618,7 @@ describe('MatSelect', () => { const value = fixture.debugElement.query(By.css('.mat-select-value')); expect(value.nativeElement.textContent) - .toContain('Pizza', `Expected trigger to be populated by the control's initial value.`); + .toContain('Pizza', `Expected trigger to be populated by the control's initial value.`); trigger = fixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement; trigger.click(); @@ -608,10 +626,10 @@ describe('MatSelect', () => { tick(SELECT_OPEN_ANIMATION); const options = - overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; + overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; expect(options[1].classList) - .toContain('mat-selected', - `Expected option with the control's initial value to be selected.`); + .toContain('mat-selected', + `Expected option with the control's initial value to be selected.`); })); it('should set the view value from the form', fakeAsync(() => { @@ -623,7 +641,7 @@ describe('MatSelect', () => { value = fixture.debugElement.query(By.css('.mat-select-value')); expect(value.nativeElement.textContent) - .toContain('Pizza', `Expected trigger to be populated by the control's new value.`); + .toContain('Pizza', `Expected trigger to be populated by the control's new value.`); trigger.click(); fixture.detectChanges(); @@ -661,18 +679,18 @@ describe('MatSelect', () => { const value = fixture.debugElement.query(By.css('.mat-select-value')); expect(value.nativeElement.textContent.trim()) - .toBe('', `Expected trigger to be cleared when option value is not found.`); + .toBe('', `Expected trigger to be cleared when option value is not found.`); expect(trigger.textContent) - .not.toContain('Pizza', `Expected trigger to be cleared when option value is not found.`); + .not.toContain('Pizza', `Expected trigger to be cleared when option value is not found.`); trigger.click(); fixture.detectChanges(); tick(SELECT_OPEN_ANIMATION); const options = - overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; + overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; expect(options[1].classList) - .not.toContain('mat-selected', `Expected option with the old value not to be selected.`); + .not.toContain('mat-selected', `Expected option with the old value not to be selected.`); })); @@ -685,18 +703,18 @@ describe('MatSelect', () => { const value = fixture.debugElement.query(By.css('.mat-select-value')); expect(value.nativeElement.textContent.trim()) - .toBe('', `Expected trigger to be cleared when option value is not found.`); + .toBe('', `Expected trigger to be cleared when option value is not found.`); expect(trigger.textContent) - .not.toContain('Pizza', `Expected trigger to be cleared when option value is not found.`); + .not.toContain('Pizza', `Expected trigger to be cleared when option value is not found.`); trigger.click(); fixture.detectChanges(); tick(SELECT_OPEN_ANIMATION); const options = - overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; + overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; expect(options[1].classList) - .not.toContain('mat-selected', `Expected option with the old value not to be selected.`); + .not.toContain('mat-selected', `Expected option with the old value not to be selected.`); })); it('should set the control to touched when the select is touched', fakeAsync(() => { @@ -771,7 +789,7 @@ describe('MatSelect', () => { requiredMarker = fixture.debugElement.query(By.css('.mat-form-field-required-marker')); expect(requiredMarker) - .not.toBeNull(`Expected placeholder to have an asterisk, as control was required.`); + .not.toBeNull(`Expected placeholder to have an asterisk, as control was required.`); }); it('should be able to programmatically select a falsy option', fakeAsync(() => { @@ -905,7 +923,7 @@ describe('MatSelect', () => { fixture.detectChanges(); const options = - overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; + overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; options[0].click(); fixture.detectChanges(); @@ -1044,7 +1062,7 @@ describe('MatSelect', () => { fixture.detectChanges(); expect(fixture.componentInstance.customAccessor.select.ngControl) - .toBe(null, 'Expected mat-select NOT to inherit control from parent value accessor.'); + .toBe(null, 'Expected mat-select NOT to inherit control from parent value accessor.'); expect(fixture.componentInstance.customAccessor.writeValue).toHaveBeenCalled(); }); @@ -1065,7 +1083,7 @@ describe('MatSelect', () => { it('should float the placeholder when the panel is open and unselected', fakeAsync(() => { expect(formField.classList.contains('mat-form-field-should-float')) - .toBe(false, 'Expected placeholder to initially have a normal position.'); + .toBe(false, 'Expected placeholder to initially have a normal position.'); fixture.componentInstance.select.open(); tick(); @@ -1073,14 +1091,14 @@ describe('MatSelect', () => { tick(SELECT_OPEN_ANIMATION); expect(formField.classList).toContain('mat-form-field-should-float', - 'Expected placeholder to animate up to floating position.'); + 'Expected placeholder to animate up to floating position.'); fixture.componentInstance.select.close(); fixture.detectChanges(); tick(SELECT_CLOSE_ANIMATION); expect(formField.classList).not.toContain('mat-form-field-should-float', - 'Expected placeholder to animate back down to normal position.'); + 'Expected placeholder to animate back down to normal position.'); })); it('should add a class to the panel when the menu is done animating', fakeAsync(() => { @@ -1132,13 +1150,13 @@ describe('MatSelect', () => { // greater than 1em. const triggerExtraLineSpaceAbove = (1 - triggerLineHeightEm) * triggerFontSize / 2; const topDifference = Math.floor(optionTop) - - Math.floor(triggerTop - triggerFontSize - triggerExtraLineSpaceAbove); + Math.floor(triggerTop - triggerFontSize - triggerExtraLineSpaceAbove); // Expect the coordinates to be within a pixel of each other. We can't rely on comparing // the exact value, because different browsers report the various sizes with slight (< 1px) // deviations. expect(Math.abs(topDifference) < 2) - .toBe(true, `Expected trigger to align with option ${index}.`); + .toBe(true, `Expected trigger to align with option ${index}.`); // 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). @@ -1147,7 +1165,7 @@ describe('MatSelect', () => { const origin = Math.floor(parseInt(rawYOrigin)); expect(origin).toBe(expectedOrigin, - `Expected panel animation to originate in the center of option ${index}.`); + `Expected panel animation to originate in the center of option ${index}.`); } describe('ample space to open', () => { @@ -1163,7 +1181,7 @@ describe('MatSelect', () => { trigger.click(); fixture.detectChanges(); - const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-select-panel')!; + const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-select-content')!; fixture.whenStable().then(() => { // The panel should be scrolled to 0 because centering the option is not possible. @@ -1181,7 +1199,7 @@ describe('MatSelect', () => { trigger.click(); fixture.detectChanges(); - const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-select-panel')!; + const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-select-content')!; fixture.whenStable().then(() => { // The panel should be scrolled to 0 because centering the option is not possible. @@ -1199,7 +1217,7 @@ describe('MatSelect', () => { trigger.click(); fixture.detectChanges(); - const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-select-panel')!; + const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-select-content')!; fixture.whenStable().then(() => { // The selected option should be scrolled to the center of the panel. @@ -1223,14 +1241,14 @@ describe('MatSelect', () => { trigger.click(); fixture.detectChanges(); - const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-select-panel')!; + const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-select-content')!; fixture.whenStable().then(() => { // The selected option should be scrolled to the max scroll position. // This will be the height of the scrollContainer - the panel height. // 8 options * 48px = 384 scrollContainer height, 384 - 256 = 128px max scroll expect(scrollContainer.scrollTop) - .toEqual(128, `Expected overlay panel to be scrolled to its maximum position.`); + .toEqual(128, `Expected overlay panel to be scrolled to its maximum position.`); checkTriggerAlignedWithOption(7); }); @@ -1256,7 +1274,7 @@ describe('MatSelect', () => { trigger.click(); groupFixture.detectChanges(); - const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-select-panel')!; + const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-select-content')!; fixture.whenStable().then(() => { // The selected option should be scrolled to the center of the panel. @@ -1264,7 +1282,7 @@ describe('MatSelect', () => { // option height. 10 (option index + 3 group labels before it) * 48 (option height) = 480 // 480 (offset from scrollTop) - 256/2 + 48/2 = 376px expect(Math.floor(scrollContainer.scrollTop)) - .toBe(376, `Expected overlay panel to be scrolled to center the selected option.`); + .toBe(376, `Expected overlay panel to be scrolled to center the selected option.`); checkTriggerAlignedWithOption(7, groupFixture.componentInstance.select); }); @@ -1300,15 +1318,15 @@ describe('MatSelect', () => { // Top-most select-position that allows for perfect centering. const topMostPositionForPerfectCentering = - idealSpaceAboveSelectedItem + selectMenuViewportPadding + - (selectItemHeight - triggerHeight) / 2; + idealSpaceAboveSelectedItem + selectMenuViewportPadding + + (selectItemHeight - triggerHeight) / 2; // Position of select relative to top edge of mat-form-field. const formFieldTopSpace = - trigger.getBoundingClientRect().top - formField.getBoundingClientRect().top; + trigger.getBoundingClientRect().top - formField.getBoundingClientRect().top; const formFieldTop = - topMostPositionForPerfectCentering - formFieldTopSpace - expectedExtraScroll; + topMostPositionForPerfectCentering - formFieldTopSpace - expectedExtraScroll; formField.style.top = `${formFieldTop}px`; @@ -1320,12 +1338,12 @@ describe('MatSelect', () => { trigger.click(); fixture.detectChanges(); - const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-select-panel')!; + const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-select-content')!; fixture.whenStable().then(() => { expect(Math.ceil(scrollContainer.scrollTop)) - .toEqual(Math.ceil(idealScrollTop + 5), - `Expected panel to adjust scroll position to fit in viewport.`); + .toEqual(Math.ceil(idealScrollTop + 5), + `Expected panel to adjust scroll position to fit in viewport.`); checkTriggerAlignedWithOption(4); }); @@ -1355,15 +1373,15 @@ describe('MatSelect', () => { // Bottom-most select-position that allows for perfect centering. const bottomMostPositionForPerfectCentering = - idealSpaceAboveSelectedItem + selectMenuViewportPadding + - (selectItemHeight - triggerHeight) / 2; + idealSpaceAboveSelectedItem + selectMenuViewportPadding + + (selectItemHeight - triggerHeight) / 2; // Position of select relative to bottom edge of mat-form-field: const formFieldBottomSpace = - formField.getBoundingClientRect().bottom - trigger.getBoundingClientRect().bottom; + formField.getBoundingClientRect().bottom - trigger.getBoundingClientRect().bottom; const formFieldBottom = - bottomMostPositionForPerfectCentering - formFieldBottomSpace - expectedExtraScroll; + bottomMostPositionForPerfectCentering - formFieldBottomSpace - expectedExtraScroll; // Push the select to a position with not quite enough space on the bottom to open // with the option completely centered (needs 113px at least: 256/2 - 48/2 + 9) @@ -1379,7 +1397,7 @@ describe('MatSelect', () => { trigger.click(); fixture.detectChanges(); - const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-select-panel')!; + const scrollContainer = document.querySelector('.cdk-overlay-pane .mat-select-content')!; fixture.whenStable().then(() => { // Scroll should adjust by the difference between the bottom space available @@ -1387,13 +1405,13 @@ describe('MatSelect', () => { // 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) - - Math.ceil(idealScrollTop - expectedExtraScroll); + Math.ceil(idealScrollTop - expectedExtraScroll); // Note that different browser/OS combinations report the different dimensions with // slight deviations (< 1px). We round the expectation and check that the values // are within a pixel of each other to avoid flakes. expect(Math.abs(difference) < 2) - .toBe(true, `Expected panel to adjust scroll position to fit in viewport.`); + .toBe(true, `Expected panel to adjust scroll position to fit in viewport.`); checkTriggerAlignedWithOption(4); }); @@ -1416,20 +1434,20 @@ 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.`); - const difference = Math.floor(overlayBottom) - Math.floor(triggerBottom); + const difference = Math.floor(overlayBottom) - Math.floor(triggerBottom); - // Check that the values are within a pixel of each other. This avoids sub-pixel - // deviations between OS and browser versions. - expect(Math.abs(difference) < 2) + // Check that the values are within a pixel of each other. This avoids sub-pixel + // deviations between OS and browser versions. + expect(Math.abs(difference) < 2) .toEqual(true, `Expected trigger bottom to align with overlay bottom.`); expect(fixture.componentInstance.select._transformOrigin) - .toContain(`bottom`, `Expected panel animation to originate at the bottom.`); + .toContain(`bottom`, `Expected panel animation to originate at the bottom.`); }); })); @@ -1449,16 +1467,16 @@ 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.`); expect(Math.floor(overlayTop)) - .toEqual(Math.floor(triggerTop), `Expected trigger top to align with overlay top.`); + .toEqual(Math.floor(triggerTop), `Expected trigger top to align with overlay top.`); expect(fixture.componentInstance.select._transformOrigin) - .toContain(`top`, `Expected panel animation to originate at the top.`); + .toContain(`top`, `Expected panel animation to originate at the top.`); }); }); })); @@ -1477,11 +1495,11 @@ describe('MatSelect', () => { fixture.detectChanges(); fixture.whenStable().then(() => { - 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.`); + `Expected select panel to be inside the viewport in ltr.`); }); })); @@ -1492,11 +1510,11 @@ describe('MatSelect', () => { fixture.detectChanges(); fixture.whenStable().then(() => { - 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.`); + `Expected select panel to be inside the viewport in rtl.`); }); })); @@ -1507,11 +1525,11 @@ describe('MatSelect', () => { fixture.whenStable().then(() => { const viewportRect = viewportRuler.getViewportRect().right; - const panelRight = document.querySelector('.mat-select-panel')! - .getBoundingClientRect().right; + const panelRight = document.querySelector('.mat-select-content')! + .getBoundingClientRect().right; expect(viewportRect - panelRight).toBeGreaterThan(0, - `Expected select panel to be inside the viewport in ltr.`); + `Expected select panel to be inside the viewport in ltr.`); }); })); @@ -1523,11 +1541,11 @@ describe('MatSelect', () => { fixture.whenStable().then(() => { const viewportRect = viewportRuler.getViewportRect().right; - const panelRight = document.querySelector('.mat-select-panel')! - .getBoundingClientRect().right; + const panelRight = document.querySelector('.mat-select-content')! + .getBoundingClientRect().right; expect(viewportRect - panelRight).toBeGreaterThan(0, - `Expected select panel to be inside the viewport in rtl.`); + `Expected select panel to be inside the viewport in rtl.`); }); })); @@ -1538,7 +1556,7 @@ describe('MatSelect', () => { tick(SELECT_OPEN_ANIMATION); fixture.whenStable().then(() => { - 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.`); @@ -1550,11 +1568,11 @@ describe('MatSelect', () => { fixture.detectChanges(); fixture.whenStable().then(() => { - panelLeft = document.querySelector('.mat-select-panel')! + panelLeft = document.querySelector('.mat-select-content')! .getBoundingClientRect().left; expect(panelLeft).toBeGreaterThan(0, - `Expected select panel continue being inside the viewport.`); + `Expected select panel continue being inside the viewport.`); }); }); }); @@ -1689,7 +1707,7 @@ describe('MatSelect', () => { // Check that the values are within a pixel of each other. This avoids sub-pixel // deviations between OS and browser versions. expect(Math.abs(difference) < 2) - .toEqual(true, `Expected trigger bottom to align with overlay bottom.`); + .toEqual(true, `Expected trigger bottom to align with overlay bottom.`); }); })); @@ -1723,7 +1741,7 @@ describe('MatSelect', () => { const overlayTop = overlayPane.getBoundingClientRect().top; expect(Math.floor(overlayTop)) - .toEqual(Math.floor(triggerTop), `Expected trigger top to align with overlay top.`); + .toEqual(Math.floor(triggerTop), `Expected trigger top to align with overlay top.`); }); })); @@ -1742,12 +1760,12 @@ describe('MatSelect', () => { fixture.whenStable().then(() => { const triggerLeft = trigger.getBoundingClientRect().left; const firstOptionLeft = document.querySelector('.cdk-overlay-pane mat-option')! - .getBoundingClientRect().left; + .getBoundingClientRect().left; // Each option is 32px wider than the trigger, so it must be adjusted 16px // to ensure the text overlaps correctly. expect(Math.floor(firstOptionLeft)).toEqual(Math.floor(triggerLeft - 16), - `Expected trigger to align with the selected option on the x-axis in LTR.`); + `Expected trigger to align with the selected option on the x-axis in LTR.`); }); })); @@ -1760,13 +1778,13 @@ describe('MatSelect', () => { fixture.whenStable().then(() => { const triggerRight = trigger.getBoundingClientRect().right; const firstOptionRight = - document.querySelector('.cdk-overlay-pane mat-option')!.getBoundingClientRect().right; + document.querySelector('.cdk-overlay-pane mat-option')!.getBoundingClientRect().right; // Each option is 32px wider than the trigger, so it must be adjusted 16px // to ensure the text overlaps correctly. expect(Math.floor(firstOptionRight)) - .toEqual(Math.floor(triggerRight + 16), - `Expected trigger to align with the selected option on the x-axis in RTL.`); + .toEqual(Math.floor(triggerRight + 16), + `Expected trigger to align with the selected option on the x-axis in RTL.`); }); })); }); @@ -1792,12 +1810,12 @@ describe('MatSelect', () => { multiFixture.whenStable().then(() => { const triggerLeft = trigger.getBoundingClientRect().left; const firstOptionLeft = - document.querySelector('.cdk-overlay-pane mat-option')!.getBoundingClientRect().left; + document.querySelector('.cdk-overlay-pane mat-option')!.getBoundingClientRect().left; // 44px accounts for the checkbox size, margin and the panel's padding. expect(Math.floor(firstOptionLeft)) - .toEqual(Math.floor(triggerLeft - 44), - `Expected trigger label to align along x-axis, accounting for the checkbox.`); + .toEqual(Math.floor(triggerLeft - 44), + `Expected trigger label to align along x-axis, accounting for the checkbox.`); }); })); @@ -1809,12 +1827,12 @@ describe('MatSelect', () => { multiFixture.whenStable().then(() => { const triggerRight = trigger.getBoundingClientRect().right; const firstOptionRight = - document.querySelector('.cdk-overlay-pane mat-option')!.getBoundingClientRect().right; + document.querySelector('.cdk-overlay-pane mat-option')!.getBoundingClientRect().right; // 44px accounts for the checkbox size, margin and the panel's padding. expect(Math.floor(firstOptionRight)) .toEqual(Math.floor(triggerRight + 44), - `Expected trigger label to align along x-axis, accounting for the checkbox.`); + `Expected trigger label to align along x-axis, accounting for the checkbox.`); }); })); }); @@ -1844,11 +1862,11 @@ describe('MatSelect', () => { const group = document.querySelector('.cdk-overlay-pane mat-optgroup')!; const triggerLeft = trigger.getBoundingClientRect().left; const selectedOptionLeft = group.querySelector('mat-option.mat-selected')! - .getBoundingClientRect().left; + .getBoundingClientRect().left; // 32px is the 16px default padding plus 16px of padding when an option is in a group. expect(Math.floor(selectedOptionLeft)).toEqual(Math.floor(triggerLeft - 32), - `Expected trigger label to align along x-axis, accounting for the padding in ltr.`); + `Expected trigger label to align along x-axis, accounting for the padding in ltr.`); }); })); @@ -1864,11 +1882,11 @@ describe('MatSelect', () => { const group = document.querySelector('.cdk-overlay-pane mat-optgroup')!; const triggerRight = trigger.getBoundingClientRect().right; const selectedOptionRight = group.querySelector('mat-option.mat-selected')! - .getBoundingClientRect().right; + .getBoundingClientRect().right; // 32px is the 16px default padding plus 16px of padding when an option is in a group. expect(Math.floor(selectedOptionRight)).toEqual(Math.floor(triggerRight + 32), - `Expected trigger label to align along x-axis, accounting for the padding in rtl.`); + `Expected trigger label to align along x-axis, accounting for the padding in rtl.`); }); })); @@ -1913,16 +1931,93 @@ describe('MatSelect', () => { let platform = new Platform(); if (platform.TRIDENT) { let difference = - Math.abs(optionTop + (menuItemHeight - triggerHeight) / 2 - triggerTop); + Math.abs(optionTop + (menuItemHeight - triggerHeight) / 2 - triggerTop); expect(difference) - .toBeLessThan(0.1, 'Expected trigger to align with the first option.'); + .toBeLessThan(0.1, 'Expected trigger to align with the first option.'); } else { expect(Math.floor(optionTop + (menuItemHeight - triggerHeight) / 2)) - .toBe(Math.floor(triggerTop), 'Expected trigger to align with the first option.'); + .toBe(Math.floor(triggerTop), 'Expected trigger to align with the first option.'); } }); })); }); + + 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('with search', () => { + let searchFixture: ComponentFixture; + let trigger: any; + + beforeEach(() => { + searchFixture = TestBed.createComponent(BasicSelectWithSearch); + searchFixture.detectChanges(); + trigger = searchFixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement; + }); + + it('should exclude options', async(() => { + trigger.click(); + searchFixture.detectChanges(); + searchFixture.whenStable().then(() => { + const searchField = document.querySelector('.mat-select-header-input')!; + // take the first label and use it to filter all options + searchField.value = searchFixture.componentInstance.foods[0].viewValue; + dispatchFakeEvent(searchField, 'input'); + searchFixture.detectChanges(); + searchFixture.whenStable().then(() => { + const optCount = document.querySelectorAll('.mat-option').length; + const exclOptCount = document.querySelectorAll('.mat-option-excluded').length; + expect(exclOptCount === (optCount - 1)) + .toBe(true, `Expected to show only the searched option.`); + }); + }); + })); }); describe('accessibility', () => { @@ -1999,7 +2094,7 @@ describe('MatSelect', () => { it('should set the mat-select-required class for required selects', () => { expect(select.classList).not.toContain( - 'mat-select-required', `Expected the mat-select-required class not to be set.`); + 'mat-select-required', `Expected the mat-select-required class not to be set.`); fixture.componentInstance.isRequired = true; fixture.detectChanges(); @@ -2123,7 +2218,7 @@ describe('MatSelect', () => { multiFixture.detectChanges(); const options = - overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; + overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; options[3].focus(); expect(document.activeElement).toBe(options[3], 'Expected fourth option to be focused.'); @@ -2132,7 +2227,7 @@ describe('MatSelect', () => { multiFixture.detectChanges(); expect(document.activeElement) - .toBe(options[3], 'Expected fourth option to remain focused.'); + .toBe(options[3], 'Expected fourth option to remain focused.'); }); it('should not cycle through the options if the control is disabled', fakeAsync(() => { @@ -2169,12 +2264,12 @@ describe('MatSelect', () => { select = multiFixture.debugElement.query(By.css('mat-select')).nativeElement; expect(multiFixture.componentInstance.select.panelOpen) - .toBe(false, 'Expected panel to be closed initially.'); + .toBe(false, 'Expected panel to be closed initially.'); dispatchKeyboardEvent(select, 'keydown', TAB); expect(multiFixture.componentInstance.select.panelOpen) - .toBe(false, 'Expected panel to stay closed.'); + .toBe(false, 'Expected panel to stay closed.'); }); it('should prevent the default action when pressing space', () => { @@ -2207,7 +2302,7 @@ describe('MatSelect', () => { const trigger = fixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement; expect(trigger.getAttribute('aria-hidden')) - .toBe('true', 'Expected aria-hidden to be true when the select is open.'); + .toBe('true', 'Expected aria-hidden to be true when the select is open.'); }); it('should set `aria-multiselectable` to true on multi-select instances', () => { @@ -2232,7 +2327,7 @@ describe('MatSelect', () => { const host = fixture.debugElement.query(By.css('mat-select')).nativeElement; expect(host.hasAttribute('aria-activedescendant')) - .toBe(false, 'Expected no aria-activedescendant on init.'); + .toBe(false, 'Expected no aria-activedescendant on init.'); fixture.componentInstance.select.open(); tick(); @@ -2350,7 +2445,7 @@ describe('MatSelect', () => { trigger.click(); fixture.detectChanges(); groups = - overlayContainerElement.querySelectorAll('mat-optgroup') as NodeListOf; + overlayContainerElement.querySelectorAll('mat-optgroup') as NodeListOf; }); it('should set the appropriate role', () => { @@ -2363,7 +2458,7 @@ describe('MatSelect', () => { expect(label.getAttribute('id')).toBeTruthy('Expected label to have an id.'); expect(group.getAttribute('aria-labelledby')) - .toBe(label.getAttribute('id'), 'Expected `aria-labelledby` to match the label id.'); + .toBe(label.getAttribute('id'), 'Expected `aria-labelledby` to match the label id.'); }); it('should set the `aria-disabled` attribute if the group is disabled', () => { @@ -2387,19 +2482,19 @@ describe('MatSelect', () => { tick(SELECT_OPEN_ANIMATION); options = - overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; + overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; })); it('should set aria-owns properly', fakeAsync(() => { const selects = fixture.debugElement.queryAll(By.css('mat-select')); expect(selects[0].nativeElement.getAttribute('aria-owns')) - .toContain(options[0].id, `Expected aria-owns to contain IDs of its child options.`); + .toContain(options[0].id, `Expected aria-owns to contain IDs of its child options.`); expect(selects[0].nativeElement.getAttribute('aria-owns')) - .toContain(options[1].id, `Expected aria-owns to contain IDs of its child options.`); + .toContain(options[1].id, `Expected aria-owns to contain IDs of its child options.`); const backdrop = - overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; + overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; backdrop.click(); fixture.detectChanges(); tick(SELECT_CLOSE_ANIMATION); @@ -2409,11 +2504,11 @@ describe('MatSelect', () => { tick(SELECT_OPEN_ANIMATION); options = - overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; + overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; expect(selects[1].nativeElement.getAttribute('aria-owns')) - .toContain(options[0].id, `Expected aria-owns to contain IDs of its child options.`); + .toContain(options[0].id, `Expected aria-owns to contain IDs of its child options.`); expect(selects[1].nativeElement.getAttribute('aria-owns')) - .toContain(options[1].id, `Expected aria-owns to contain IDs of its child options.`); + .toContain(options[1].id, `Expected aria-owns to contain IDs of its child options.`); })); @@ -2421,11 +2516,11 @@ describe('MatSelect', () => { let firstOptionID = options[0].id; expect(options[0].id) - .toContain('mat-option', `Expected option ID to have the correct prefix.`); + .toContain('mat-option', `Expected option ID to have the correct prefix.`); expect(options[0].id).not.toEqual(options[1].id, `Expected option IDs to be unique.`); const backdrop = - overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; + overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; backdrop.click(); fixture.detectChanges(); tick(SELECT_CLOSE_ANIMATION); @@ -2435,9 +2530,9 @@ describe('MatSelect', () => { tick(SELECT_OPEN_ANIMATION); options = - overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; + overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; expect(options[0].id) - .toContain('mat-option', `Expected option ID to have the correct prefix.`); + .toContain('mat-option', `Expected option ID to have the correct prefix.`); expect(options[0].id).not.toEqual(firstOptionID, `Expected option IDs to be unique.`); expect(options[0].id).not.toEqual(options[1].id, `Expected option IDs to be unique.`); })); @@ -2464,7 +2559,7 @@ describe('MatSelect', () => { const value = fixture.debugElement.query(By.css('.mat-select-value')); expect(value.nativeElement.textContent) - .toContain('Pizza', `Expected trigger to be populated by the control's initial value.`); + .toContain('Pizza', `Expected trigger to be populated by the control's initial value.`); const pane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; expect(pane.style.minWidth).toEqual('300px'); @@ -2517,7 +2612,7 @@ describe('MatSelect', () => { const label = fixture.debugElement.query(By.css('.mat-select-value')).nativeElement; expect(label.textContent).toContain('azziP', - 'Expected the displayed text to be "Pizza" in reverse.'); + 'Expected the displayed text to be "Pizza" in reverse.'); }); }); @@ -2578,13 +2673,13 @@ describe('MatSelect', () => { fixture.detectChanges(); expect(formField.classList.contains('mat-form-field-can-float')) - .toBe(false, 'Floating placeholder should be disabled'); + .toBe(false, 'Floating placeholder should be disabled'); fixture.componentInstance.control.setValue('pizza-1'); fixture.detectChanges(); expect(formField.classList.contains('mat-form-field-can-float')) - .toBe(false, 'Floating placeholder should be disabled'); + .toBe(false, 'Floating placeholder should be disabled'); }); it('should be able to always float the placeholder', () => { @@ -2594,12 +2689,12 @@ describe('MatSelect', () => { fixture.detectChanges(); expect(formField.classList.contains('mat-form-field-can-float')) - .toBe(true, 'Placeholder should be able to float'); + .toBe(true, 'Placeholder should be able to float'); expect(formField.classList.contains('mat-form-field-should-float')) - .toBe(true, 'Placeholder should be floating'); + .toBe(true, 'Placeholder should be floating'); }); - it ('should default to global floating placeholder type', () => { + it('should default to global floating placeholder type', () => { fixture.destroy(); TestBed.resetTestingModule(); @@ -2623,9 +2718,9 @@ describe('MatSelect', () => { formField = fixture.debugElement.query(By.css('.mat-form-field')).nativeElement; expect(formField.classList.contains('mat-form-field-can-float')) - .toBe(true, 'Placeholder should be able to float'); + .toBe(true, 'Placeholder should be able to float'); expect(formField.classList.contains('mat-form-field-should-float')) - .toBe(true, 'Placeholder should be floating'); + .toBe(true, 'Placeholder should be floating'); }); }); @@ -2680,7 +2775,7 @@ describe('MatSelect', () => { fixture.detectChanges(); const options = overlayContainerElement.querySelectorAll('mat-option') as - NodeListOf; + NodeListOf; options[0].click(); options[2].click(); @@ -2713,7 +2808,7 @@ describe('MatSelect', () => { tick(SELECT_OPEN_ANIMATION); const options = overlayContainerElement.querySelectorAll('mat-option') as - NodeListOf; + NodeListOf; options[0].click(); options[2].click(); @@ -2734,7 +2829,7 @@ describe('MatSelect', () => { fixture.detectChanges(); const optionNodes = overlayContainerElement.querySelectorAll('mat-option') as - NodeListOf; + NodeListOf; const optionInstances = testInstance.options.toArray(); @@ -2750,7 +2845,7 @@ describe('MatSelect', () => { fixture.detectChanges(); const options = overlayContainerElement.querySelectorAll('mat-option') as - NodeListOf; + NodeListOf; options[0].click(); fixture.detectChanges(); @@ -2771,7 +2866,7 @@ describe('MatSelect', () => { expect(testInstance.select.panelOpen).toBe(true); const options = overlayContainerElement.querySelectorAll('mat-option') as - NodeListOf; + NodeListOf; options[0].click(); options[1].click(); @@ -2786,7 +2881,7 @@ describe('MatSelect', () => { tick(SELECT_OPEN_ANIMATION); const options = overlayContainerElement.querySelectorAll('mat-option') as - NodeListOf; + NodeListOf; options[2].click(); options[0].click(); @@ -2804,7 +2899,7 @@ describe('MatSelect', () => { tick(SELECT_OPEN_ANIMATION); const options = overlayContainerElement.querySelectorAll('mat-option') as - NodeListOf; + NodeListOf; options[2].click(); options[0].click(); @@ -2854,13 +2949,13 @@ describe('MatSelect', () => { tick(SELECT_OPEN_ANIMATION); expect(testInstance.options.toArray().every(option => !!option.multiple)).toBe(true, - 'Expected `multiple` to have been added to initial set of options.'); + 'Expected `multiple` to have been added to initial set of options.'); testInstance.foods.push({ value: 'cake-8', viewValue: 'Cake' }); fixture.detectChanges(); expect(testInstance.options.toArray().every(option => !!option.multiple)).toBe(true, - 'Expected `multiple` to have been set on dynamically-added option.'); + 'Expected `multiple` to have been set on dynamically-added option.'); })); }); @@ -2982,24 +3077,24 @@ describe('MatSelect', () => { expect(testComponent.formGroup.untouched).toBe(true, 'Expected the form to be untouched.'); expect(testComponent.formControl.invalid).toBe(true, 'Expected form control to be invalid.'); expect(select.classList) - .not.toContain('mat-select-invalid', 'Expected select not to appear invalid.'); + .not.toContain('mat-select-invalid', 'Expected select not to appear invalid.'); expect(select.getAttribute('aria-invalid')) - .toBe('false', 'Expected aria-invalid to be set to false.'); + .toBe('false', 'Expected aria-invalid to be set to false.'); }); it('should appear as invalid if it becomes touched', () => { expect(select.classList) - .not.toContain('mat-select-invalid', 'Expected select not to appear invalid.'); + .not.toContain('mat-select-invalid', 'Expected select not to appear invalid.'); expect(select.getAttribute('aria-invalid')) - .toBe('false', 'Expected aria-invalid to be set to false.'); + .toBe('false', 'Expected aria-invalid to be set to false.'); testComponent.formControl.markAsTouched(); fixture.detectChanges(); expect(select.classList) - .toContain('mat-select-invalid', 'Expected select to appear invalid.'); + .toContain('mat-select-invalid', 'Expected select to appear invalid.'); expect(select.getAttribute('aria-invalid')) - .toBe('true', 'Expected aria-invalid to be set to true.'); + .toBe('true', 'Expected aria-invalid to be set to true.'); }); it('should not have the invalid class when the select becomes valid', () => { @@ -3007,32 +3102,32 @@ describe('MatSelect', () => { fixture.detectChanges(); expect(select.classList) - .toContain('mat-select-invalid', 'Expected select to appear invalid.'); + .toContain('mat-select-invalid', 'Expected select to appear invalid.'); expect(select.getAttribute('aria-invalid')) - .toBe('true', 'Expected aria-invalid to be set to true.'); + .toBe('true', 'Expected aria-invalid to be set to true.'); testComponent.formControl.setValue('pizza-1'); fixture.detectChanges(); expect(select.classList) - .not.toContain('mat-select-invalid', 'Expected select not to appear invalid.'); + .not.toContain('mat-select-invalid', 'Expected select not to appear invalid.'); expect(select.getAttribute('aria-invalid')) - .toBe('false', 'Expected aria-invalid to be set to false.'); + .toBe('false', 'Expected aria-invalid to be set to false.'); }); it('should appear as invalid when the parent form group is submitted', () => { expect(select.classList) - .not.toContain('mat-select-invalid', 'Expected select not to appear invalid.'); + .not.toContain('mat-select-invalid', 'Expected select not to appear invalid.'); expect(select.getAttribute('aria-invalid')) - .toBe('false', 'Expected aria-invalid to be set to false.'); + .toBe('false', 'Expected aria-invalid to be set to false.'); dispatchFakeEvent(fixture.debugElement.query(By.css('form')).nativeElement, 'submit'); fixture.detectChanges(); expect(select.classList) - .toContain('mat-select-invalid', 'Expected select to appear invalid.'); + .toContain('mat-select-invalid', 'Expected select to appear invalid.'); expect(select.getAttribute('aria-invalid')) - .toBe('true', 'Expected aria-invalid to be set to true.'); + .toBe('true', 'Expected aria-invalid to be set to true.'); }); it('should render the error messages when the parent form is submitted', () => { @@ -3164,7 +3259,7 @@ describe('MatSelect', () => { fixture.componentInstance.foods = []; for (let i = 0; i < 30; i++) { - fixture.componentInstance.foods.push({value: `value-${i}`, viewValue: `Option ${i}`}); + fixture.componentInstance.foods.push({ value: `value-${i}`, viewValue: `Option ${i}` }); } fixture.detectChanges(); @@ -3173,7 +3268,7 @@ describe('MatSelect', () => { tick(SELECT_OPEN_ANIMATION); 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(() => { @@ -3221,7 +3316,7 @@ describe('MatSelect', () => { tick(SELECT_OPEN_ANIMATION); 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); @@ -3358,7 +3453,7 @@ class NgModelSelect { ` }) -class ManySelects {} +class ManySelects { } @Component({ selector: 'ng-if-select', @@ -3379,7 +3474,7 @@ class NgIfSelect { foods: any[] = [ { value: 'steak-0', viewValue: 'Steak' }, { value: 'pizza-1', viewValue: 'Pizza' }, - { value: 'tacos-2', viewValue: 'Tacos'} + { value: 'tacos-2', viewValue: 'Tacos' } ]; control = new FormControl('pizza-1'); @@ -3434,7 +3529,7 @@ class SelectInitWithoutOptions { this.foods = [ { value: 'steak-0', viewValue: 'Steak' }, { value: 'pizza-1', viewValue: 'Pizza' }, - { value: 'tacos-2', viewValue: 'Tacos'} + { value: 'tacos-2', viewValue: 'Tacos' } ]; } } @@ -3451,9 +3546,9 @@ class SelectInitWithoutOptions { class CustomSelectAccessor implements ControlValueAccessor { @ViewChild(MatSelect) select: MatSelect; - writeValue: (value?: any) => void = () => {}; - registerOnChange: (changeFn?: (value: any) => void) => void = () => {}; - registerOnTouched: (touchedFn?: () => void) => void = () => {}; + writeValue: (value?: any) => void = () => { }; + registerOnChange: (changeFn?: (value: any) => void) => void = () => { }; + registerOnTouched: (touchedFn?: () => void) => void = () => { }; } @Component({ @@ -3555,7 +3650,7 @@ class FloatPlaceholderSelect { foods: any[] = [ { value: 'steak-0', viewValue: 'Steak' }, { value: 'pizza-1', viewValue: 'Pizza' }, - { value: 'tacos-2', viewValue: 'Tacos'} + { value: 'tacos-2', viewValue: 'Tacos' } ]; @ViewChild(MatSelect) select: MatSelect; @@ -3896,13 +3991,13 @@ class SelectWithCustomTrigger { ` }) class NgModelCompareWithSelect { - foods: ({value: string, viewValue: string})[] = [ + foods: ({ value: string, viewValue: string })[] = [ { value: 'steak-0', viewValue: 'Steak' }, { value: 'pizza-1', viewValue: 'Pizza' }, { value: 'tacos-2', viewValue: 'Tacos' }, ]; - selectedFood: {value: string, viewValue: string} = { value: 'pizza-1', viewValue: 'Pizza' }; - comparator: ((f1: any, f2: any) => boolean)|null = this.compareByValue; + selectedFood: { value: string, viewValue: string } = { value: 'pizza-1', viewValue: 'Pizza' }; + comparator: ((f1: any, f2: any) => boolean) | null = this.compareByValue; @ViewChild(MatSelect) select: MatSelect; @ViewChildren(MatOption) options: QueryList; @@ -3917,7 +4012,7 @@ class NgModelCompareWithSelect { compareByReference(f1: any, f2: any) { return f1 === f2; } - setFoodByCopy(newValue: {value: string, viewValue: string}) { + setFoodByCopy(newValue: { value: string, viewValue: string }) { this.selectedFood = extendObject({}, newValue); } } @@ -3965,3 +4060,70 @@ 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; +} + +@Component({ + selector: 'basic-select-with-search', + template: ` + + + + + + + + {{ food.viewValue }} + + + + ` +}) +class BasicSelectWithSearch { + 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 8ad70fc9d2dd..0d29eb466693 100644 --- a/src/lib/select/select.ts +++ b/src/lib/select/select.ts @@ -10,7 +10,7 @@ import {ActiveDescendantKeyManager} from '@angular/cdk/a11y'; import {Directionality} from '@angular/cdk/bidi'; import {coerceBooleanProperty} from '@angular/cdk/coercion'; import {SelectionModel} from '@angular/cdk/collections'; -import {DOWN_ARROW, END, ENTER, HOME, SPACE, UP_ARROW} from '@angular/cdk/keycodes'; +import { DOWN_ARROW, END, ENTER, HOME, SPACE, UP_ARROW, TAB } from '@angular/cdk/keycodes'; import { ConnectedOverlayDirective, Overlay, @@ -71,6 +71,8 @@ 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 {MatSelectSearch} from './select-search'; import { getMatSelectDynamicMultipleError, getMatSelectNonArrayValueError, @@ -293,6 +295,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; @@ -314,6 +319,12 @@ 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; + + /** The select's search, if specified */ + @ContentChild(MatSelectSearch) search: MatSelectSearch; + /** Placeholder to be shown if no value has been selected. */ @Input() get placeholder() { return this._placeholder; } @@ -451,6 +462,11 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit, ngAfterContentInit() { this._initKeyManager(); + if (this.search) { + this.search.onSearch.subscribe((searchTerms) => { + this._excludeOptions(searchTerms); + }); + } RxChain.from(this.options.changes) .call(startWith, null) @@ -487,7 +503,7 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit, /** Opens the overlay panel. */ open(): void { - if (this.disabled || !this.options.length) { + if (this.disabled || !this.options.length && !this.search) { return; } @@ -619,6 +635,10 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit, /** Handles keyboard events when the selected is open. */ private _handleOpenKeydown(event: KeyboardEvent): void { + if (this.search && this.search.focused) { + this._handleSearchKeydown(event); + return; + } const keyCode = event.keyCode; if (keyCode === HOME || keyCode === END) { @@ -633,6 +653,37 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit, } } + /** Handles keyboard events when select search is focused. */ + private _handleSearchKeydown(event: KeyboardEvent): void { + const keyCode = event.keyCode; + const keyManager = this._keyManager; + + if (keyCode === ENTER && keyManager.activeItem) { + event.preventDefault(); + keyManager.activeItem._selectViaInteraction(); + } else { + switch (keyCode) { + case DOWN_ARROW: keyManager.setNextItemActive(); break; + case UP_ARROW: keyManager.setPreviousItemActive(); break; + case TAB: keyManager.tabOut.next(); + default: return; + } + event.preventDefault(); + } + } + + /** Handles search using select-search */ + private _excludeOptions(searchTerms: string) { + if (this.search.remoteSearch) { + return; + } + const matcher = this.search.filterMatchFactory(searchTerms); + this.options.forEach(o => o.setExcludeStyles(!matcher(o.getLabel()))); + if (this.panelOpen) { + this._keyManager.setFirstItemActive(); + } + } + /** * When the panel element is finished transforming in (though not fading in), it * emits an event and focuses an option if the panel is open. @@ -641,11 +692,19 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit, if (this.panelOpen) { this._scrollTop = 0; this.onOpen.emit(); + + if (this.header) { + this.header._trapFocus(); + } } else { this.onClose.emit(); this._panelDoneAnimating = false; this.overlayDir.offsetX = 0; this._changeDetectorRef.markForCheck(); + + if (this.search) { + this.search.resetSearch(); + } } } @@ -807,6 +866,12 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit, }); this._setOptionIds(); + + if (this._panelOpen) { + if (this.options.length) { + this._keyManager.setFirstItemActive(); + } + } } /** Invoked when an option is clicked. */ @@ -930,7 +995,7 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit, this.empty ? 0 : this._getOptionIndex(this._selectionModel.selected[0])!; selectedOptionOffset += MatOption.countGroupLabelsBeforeOption(selectedOptionOffset, - this.options, this.optionGroups); + this.options, this.optionGroups) + (this.header ? 1 : 0); // We must maintain a scroll buffer so the selected option will be scrolled to the // center of the overlay panel rather than the top. 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..a30bf9557f0a --- /dev/null +++ b/src/material-examples/select-header/select-header-example.html @@ -0,0 +1,12 @@ + + + with water please! + + + + {{food.viewValue}} + + +with water! + +

Selected value: {{selectedValue}}

diff --git a/src/material-examples/select-header/select-header-example.ts b/src/material-examples/select-header/select-header-example.ts new file mode 100644 index 000000000000..a7567e2c188a --- /dev/null +++ b/src/material-examples/select-header/select-header-example.ts @@ -0,0 +1,22 @@ +import {Component} from '@angular/core'; + + +@Component({ + selector: 'select-header-example', + templateUrl: './select-header-example.html', +}) +export class SelectHeaderExample { + selectedValue: string; + withWater: boolean; + + 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'}, + ]; +} diff --git a/src/material-examples/select-search/select-search-example.css b/src/material-examples/select-search/select-search-example.css new file mode 100644 index 000000000000..7432308753e6 --- /dev/null +++ b/src/material-examples/select-search/select-search-example.css @@ -0,0 +1 @@ +/** No CSS for this example */ diff --git a/src/material-examples/select-search/select-search-example.html b/src/material-examples/select-search/select-search-example.html new file mode 100644 index 000000000000..33d9bf595754 --- /dev/null +++ b/src/material-examples/select-search/select-search-example.html @@ -0,0 +1,11 @@ + + + + + + + {{food.viewValue}} + + + +

Selected value: {{selectedValue}}

diff --git a/src/material-examples/select-search/select-search-example.ts b/src/material-examples/select-search/select-search-example.ts new file mode 100644 index 000000000000..486a9b3aa6b1 --- /dev/null +++ b/src/material-examples/select-search/select-search-example.ts @@ -0,0 +1,28 @@ +import {Component} from '@angular/core'; + + +@Component({ + selector: 'select-header-example', + templateUrl: './select-header-example.html', +}) +export class SelectHeaderExample { + selectedValue: string; + withWater: boolean; + + 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'}, + ]; + + startsWithFilter(search: string) { + return (l: string) => { + return l.toLowerCase().indexOf(search.toLowerCase()) === 0; + }; + } +}