diff --git a/src/app/playground-components.ts b/src/app/playground-components.ts index f8782fe708..309d3a8b53 100644 --- a/src/app/playground-components.ts +++ b/src/app/playground-components.ts @@ -1162,10 +1162,10 @@ export const PLAYGROUND_COMPONENTS: ComponentLink[] = [ name: 'Select Icon', }, { - path: 'select-search-showcase.component', - link: '/select/select-search-showcase.component', - component: 'SelectSearchShowcaseComponent', - name: 'Select Search Showcase', + path: 'select-autocomplete-showcase.component', + link: '/select/select-autocomplete-showcase.component', + component: 'SelectAutocompleteShowcaseComponent', + name: 'Select Autocomplete Showcase', }, ], }, diff --git a/src/framework/theme/components/option/option.component.ts b/src/framework/theme/components/option/option.component.ts index 0d896dbcab..8dc4ef8dd2 100644 --- a/src/framework/theme/components/option/option.component.ts +++ b/src/framework/theme/components/option/option.component.ts @@ -82,16 +82,11 @@ import { NbSelectComponent } from '../select/select.component'; styleUrls: ['./option.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, template: ` - + `, }) export class NbOptionComponent implements OnDestroy, AfterViewInit, NbFocusableOption, NbHighlightableOption { - protected disabledByGroup = false; /** @@ -132,11 +127,13 @@ export class NbOptionComponent implements OnDestroy, AfterViewInit, NbF @HostBinding('attr.id') id: string = `nb-option-${lastOptionId++}`; - constructor(@Optional() @Inject(NB_SELECT_INJECTION_TOKEN) parent, - protected elementRef: ElementRef, - protected cd: ChangeDetectorRef, - protected zone: NgZone, - protected renderer: Renderer2) { + constructor( + @Optional() @Inject(NB_SELECT_INJECTION_TOKEN) parent, + protected elementRef: ElementRef, + protected cd: ChangeDetectorRef, + protected zone: NgZone, + protected renderer: Renderer2, + ) { this.parent = parent; } @@ -146,9 +143,11 @@ export class NbOptionComponent implements OnDestroy, AfterViewInit, NbF ngAfterViewInit() { // TODO: #2254 - this.zone.runOutsideAngular(() => setTimeout(() => { - this.renderer.addClass(this.elementRef.nativeElement, 'nb-transition'); - })); + this.zone.runOutsideAngular(() => + setTimeout(() => { + this.renderer.addClass(this.elementRef.nativeElement, 'nb-transition'); + }), + ); } /** @@ -162,6 +161,10 @@ export class NbOptionComponent implements OnDestroy, AfterViewInit, NbF return this.elementRef.nativeElement.textContent; } + get hidden() { + return this.elementRef.nativeElement.hidden; + } + // TODO: replace with isShowCheckbox property to control this behaviour outside, issues/1965 @HostBinding('class.multiple') get multiple() { @@ -188,7 +191,7 @@ export class NbOptionComponent implements OnDestroy, AfterViewInit, NbF @HostBinding('class.active') get activeClass() { return this._active; - }; + } protected _active: boolean = false; @HostListener('click', ['$event']) @@ -252,5 +255,4 @@ export class NbOptionComponent implements OnDestroy, AfterViewInit, NbF this._active = false; this.cd.markForCheck(); } - } diff --git a/src/framework/theme/components/select-with-autocomplete/_select-with-autocomplete-filled.scss b/src/framework/theme/components/select-with-autocomplete/_select-with-autocomplete-filled.scss new file mode 100644 index 0000000000..fc8ed37892 --- /dev/null +++ b/src/framework/theme/components/select-with-autocomplete/_select-with-autocomplete-filled.scss @@ -0,0 +1,57 @@ +@use '../../styles/theming' as *; +@use '../form-field/form-field.component.theme' as form-field-theme; + +@mixin select-with-autocomplete-filled { + nb-select-with-autocomplete.appearance-filled .select-button { + border-style: nb-theme(select-filled-border-style); + border-width: nb-theme(select-filled-border-width); + } + + @each $size in nb-get-sizes() { + nb-select-with-autocomplete.appearance-filled.size-#{$size} .select-button { + padding: nb-theme(select-filled-#{$size}-padding); + @include nb-ltr(padding-right, nb-theme(select-icon-offset)); + @include nb-rtl(padding-left, nb-theme(select-icon-offset)); + } + + @include form-field-theme.nb-form-field-with-prefix( + 'nb-select-with-autocomplete.appearance-filled.size-#{$size} .select-button', + $size + ); + } + + @each $status in nb-get-statuses() { + nb-select-with-autocomplete.appearance-filled.status-#{$status} .select-button { + background-color: nb-theme(select-filled-#{$status}-background-color); + border-color: nb-theme(select-filled-#{$status}-border-color); + color: nb-theme(select-filled-#{$status}-text-color); + + &.placeholder { + color: nb-theme(select-filled-#{$status}-placeholder-text-color); + } + + &:focus { + background-color: nb-theme(select-filled-#{$status}-focus-background-color); + border-color: nb-theme(select-filled-#{$status}-focus-border-color); + } + &:hover { + background-color: nb-theme(select-filled-#{$status}-hover-background-color); + border-color: nb-theme(select-filled-#{$status}-hover-border-color); + } + + &[disabled] { + background-color: nb-theme(select-filled-#{$status}-disabled-background-color); + border-color: nb-theme(select-filled-#{$status}-disabled-border-color); + color: nb-theme(select-filled-#{$status}-disabled-text-color); + + nb-icon { + color: nb-theme(select-filled-#{$status}-disabled-icon-color); + } + } + + nb-icon { + color: nb-theme(select-filled-#{$status}-icon-color); + } + } + } +} diff --git a/src/framework/theme/components/select-with-autocomplete/_select-with-autocomplete-hero.scss b/src/framework/theme/components/select-with-autocomplete/_select-with-autocomplete-hero.scss new file mode 100644 index 0000000000..aba656a9b3 --- /dev/null +++ b/src/framework/theme/components/select-with-autocomplete/_select-with-autocomplete-hero.scss @@ -0,0 +1,57 @@ +@use '../../styles/theming' as *; +@use '../form-field/form-field.component.theme' as form-field-theme; + +@mixin select-with-autocomplete-hero { + nb-select-with-autocomplete.appearance-hero .select-button { + border: none; + } + + @each $size in nb-get-sizes() { + nb-select-with-autocomplete.appearance-hero.size-#{$size} .select-button { + padding: nb-theme(select-hero-#{$size}-padding); + @include nb-ltr(padding-right, nb-theme(select-icon-offset)); + @include nb-rtl(padding-left, nb-theme(select-icon-offset)); + } + @include form-field-theme.nb-form-field-with-prefix( + 'nb-select-with-autocomplete.appearance-hero.size-#{$size} .select-button', + $size + ); + } + + @each $status in nb-get-statuses() { + nb-select-with-autocomplete.appearance-hero.status-#{$status} .select-button { + $left-color: nb-theme(select-hero-#{$status}-left-background-color); + $right-color: nb-theme(select-hero-#{$status}-right-background-color); + background-image: linear-gradient(to right, $left-color, $right-color); + color: nb-theme(select-hero-#{$status}-text-color); + + &.placeholder { + color: nb-theme(select-hero-#{$status}-placeholder-text-color); + } + + &:focus { + $left-color: nb-theme(select-hero-#{$status}-focus-left-background-color); + $right-color: nb-theme(select-hero-#{$status}-focus-right-background-color); + background-image: linear-gradient(to right, $left-color, $right-color); + } + &:hover { + $left-color: nb-theme(select-hero-#{$status}-hover-left-background-color); + $right-color: nb-theme(select-hero-#{$status}-hover-right-background-color); + background-image: linear-gradient(to right, $left-color, $right-color); + } + &[disabled] { + color: nb-theme(select-hero-#{$status}-disabled-text-color); + background-color: nb-theme(select-hero-#{$status}-disabled-background-color); + background-image: none; + + nb-icon { + color: nb-theme(select-hero-#{$status}-disabled-icon-color); + } + } + + nb-icon { + color: nb-theme(select-hero-#{$status}-icon-color); + } + } + } +} diff --git a/src/framework/theme/components/select-with-autocomplete/_select-with-autocomplete-outline.scss b/src/framework/theme/components/select-with-autocomplete/_select-with-autocomplete-outline.scss new file mode 100644 index 0000000000..176913da04 --- /dev/null +++ b/src/framework/theme/components/select-with-autocomplete/_select-with-autocomplete-outline.scss @@ -0,0 +1,77 @@ +@use '../../styles/theming' as *; +@use '../form-field/form-field.component.theme' as form-field-theme; + +@mixin select-with-autocomplete-outline { + nb-select-with-autocomplete.appearance-outline .select-button { + border-style: nb-theme(select-outline-border-style); + border-width: nb-theme(select-outline-border-width); + + &.top { + border-top-style: nb-theme(select-outline-adjacent-border-style); + border-top-width: nb-theme(select-outline-adjacent-border-width); + } + &.bottom { + border-bottom-style: nb-theme(select-outline-adjacent-border-style); + border-bottom-width: nb-theme(select-outline-adjacent-border-width); + } + } + + @each $status in nb-get-statuses() { + nb-select-with-autocomplete.appearance-outline.status-#{$status} .select-button { + background-color: nb-theme(select-outline-#{$status}-background-color); + border-color: nb-theme(select-outline-#{$status}-border-color); + color: nb-theme(select-outline-#{$status}-text-color); + + &.placeholder { + color: nb-theme(select-outline-#{$status}-placeholder-text-color); + } + nb-icon { + color: nb-theme(select-outline-#{$status}-icon-color); + } + + &:focus { + background-color: nb-theme(select-outline-#{$status}-focus-background-color); + border-color: nb-theme(select-outline-#{$status}-focus-border-color); + } + &:hover { + background-color: nb-theme(select-outline-#{$status}-hover-background-color); + border-color: nb-theme(select-outline-#{$status}-hover-border-color); + } + + &[disabled] { + color: nb-theme(select-outline-#{$status}-disabled-text-color); + background-color: nb-theme(select-outline-#{$status}-disabled-background-color); + border-color: nb-theme(select-outline-#{$status}-disabled-border-color); + + nb-icon { + color: nb-theme(select-outline-#{$status}-disabled-icon-color); + } + } + + &.bottom, + &.top { + border-color: nb-theme(select-outline-#{$status}-open-border-color); + } + + &.top { + border-top-color: nb-theme(select-outline-#{$status}-adjacent-border-color); + } + &.bottom { + border-bottom-color: nb-theme(select-outline-#{$status}-adjacent-border-color); + } + } + } + + @each $size in nb-get-sizes() { + nb-select-with-autocomplete.appearance-outline.size-#{$size} .select-button { + padding: nb-theme(select-outline-#{$size}-padding); + @include nb-ltr(padding-right, nb-theme(select-icon-offset)); + @include nb-rtl(padding-left, nb-theme(select-icon-offset)); + } + + @include form-field-theme.nb-form-field-with-prefix( + 'nb-select-with-autocomplete.appearance-outline.size-#{$size} .select-button', + $size + ); + } +} diff --git a/src/framework/theme/components/select-with-autocomplete/_select-with-autocomplete.component.theme.scss b/src/framework/theme/components/select-with-autocomplete/_select-with-autocomplete.component.theme.scss new file mode 100644 index 0000000000..168a73f764 --- /dev/null +++ b/src/framework/theme/components/select-with-autocomplete/_select-with-autocomplete.component.theme.scss @@ -0,0 +1,66 @@ +/* + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +@use '../../styles/theming' as *; +@use '../form-field/form-field.component.theme' as form-field-theme; +@use 'select-with-autocomplete-outline'; +@use 'select-with-autocomplete-filled'; +@use 'select-with-autocomplete-hero'; + +@mixin nb-select-with-autocomplete-theme() { + nb-select-with-autocomplete .select-button { + min-width: nb-theme(select-min-width); + cursor: nb-theme(select-cursor); + font-family: nb-theme(select-text-font-family); + + &.placeholder { + font-family: nb-theme(select-placeholder-text-font-family); + } + &:focus { + outline: none; + } + &[disabled] { + cursor: nb-theme(select-disabled-cursor); + } + } + + @each $size in nb-get-sizes() { + nb-select-with-autocomplete.size-#{$size} { + .select-button { + font-size: nb-theme(select-#{$size}-text-font-size); + font-weight: nb-theme(select-#{$size}-text-font-weight); + line-height: nb-theme(select-#{$size}-text-line-height); + + &.placeholder { + font-size: nb-theme(select-#{$size}-placeholder-text-font-size); + font-weight: nb-theme(select-#{$size}-placeholder-text-font-weight); + } + + &.empty::before { + content: ' '; + display: block; + height: nb-theme(select-#{$size}-text-line-height); + } + } + + &:not(.full-width) { + max-width: nb-theme(select-#{$size}-max-width); + } + } + } + + @each $shape in nb-get-shapes() { + nb-select-with-autocomplete.shape-#{$shape} .select-button { + border-radius: nb-theme(select-#{$shape}-border-radius); + } + } + + @include select-with-autocomplete-outline.select-with-autocomplete-outline(); + @include select-with-autocomplete-filled.select-with-autocomplete-filled(); + @include select-with-autocomplete-hero.select-with-autocomplete-hero(); + + @include form-field-theme.nb-form-field-root-component('nb-select-with-autocomplete'); +} diff --git a/src/framework/theme/components/select-with-autocomplete/select-with-autocomplete.component.html b/src/framework/theme/components/select-with-autocomplete/select-with-autocomplete.component.html new file mode 100644 index 0000000000..7dda17520e --- /dev/null +++ b/src/framework/theme/components/select-with-autocomplete/select-with-autocomplete.component.html @@ -0,0 +1,58 @@ + + + + + + + + + + diff --git a/src/framework/theme/components/select-with-autocomplete/select-with-autocomplete.component.scss b/src/framework/theme/components/select-with-autocomplete/select-with-autocomplete.component.scss new file mode 100644 index 0000000000..6f3327cf5f --- /dev/null +++ b/src/framework/theme/components/select-with-autocomplete/select-with-autocomplete.component.scss @@ -0,0 +1,59 @@ +/*! + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +@use '../../styles/theming' as *; + +:host { + display: inline-block; + max-width: 100%; + + .select-button { + @include nb-ltr(text-align, left) { + nb-icon { + right: 0.2em; + } + } + @include nb-rtl(text-align, right) { + nb-icon { + left: 0.2em; + } + } + } +} + +:host(.full-width) { + width: 100%; +} + +:host(.nb-transition) { + .select-button { + @include nb-component-animation(background-color, border-color, border-radius, box-shadow, color); + } +} + +.select-button, +nb-form-field { + position: relative; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + text-transform: none; + white-space: nowrap; +} + +nb-icon:not([nbSuffix]) { + font-size: 1.5em; + position: absolute; + top: 50%; + transform: translateY(-50%); + @include nb-ltr(right, 0.5rem); + @include nb-rtl(left, 0.5rem); + @include nb-component-animation(transform); +} + +:host(.open) nb-icon:not([nbSuffix]) { + transform: translateY(-50%) rotate(180deg); +} diff --git a/src/framework/theme/components/select-with-autocomplete/select-with-autocomplete.component.ts b/src/framework/theme/components/select-with-autocomplete/select-with-autocomplete.component.ts new file mode 100644 index 0000000000..0468276e2c --- /dev/null +++ b/src/framework/theme/components/select-with-autocomplete/select-with-autocomplete.component.ts @@ -0,0 +1,1040 @@ +/* + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { + AfterContentInit, + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ComponentRef, + ContentChild, + ContentChildren, + ElementRef, + EventEmitter, + forwardRef, + HostBinding, + Inject, + Input, + OnDestroy, + Output, + QueryList, + ViewChild, + SimpleChanges, + OnChanges, + Renderer2, + NgZone, +} from '@angular/core'; +import { NgClass } from '@angular/common'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { ListKeyManager } from '@angular/cdk/a11y'; +import { merge, Subject, BehaviorSubject, from, combineLatest, animationFrameScheduler, EMPTY } from 'rxjs'; +import { startWith, switchMap, takeUntil, filter, map, finalize, take, observeOn } from 'rxjs/operators'; + +import { NbStatusService } from '../../services/status.service'; +import { + NbAdjustableConnectedPositionStrategy, + NbAdjustment, + NbPosition, + NbPositionBuilderService, +} from '../cdk/overlay/overlay-position'; +import { NbOverlayRef, NbPortalDirective, NbScrollStrategy } from '../cdk/overlay/mapping'; +import { NbOverlayService } from '../cdk/overlay/overlay-service'; +import { NbTrigger, NbTriggerStrategy, NbTriggerStrategyBuilderService } from '../cdk/overlay/overlay-trigger'; +import { NbFocusKeyManager, NbFocusKeyManagerFactoryService } from '../cdk/a11y/focus-key-manager'; +import { ENTER, ESCAPE } from '../cdk/keycodes/keycodes'; +import { NbComponentSize } from '../component-size'; +import { NbComponentShape } from '../component-shape'; +import { NbComponentOrCustomStatus } from '../component-status'; +import { NB_DOCUMENT } from '../../theme.options'; +import { NbOptionComponent } from '../option/option.component'; +import { convertToBoolProperty, NbBooleanInput } from '../helpers'; +import { NB_SELECT_INJECTION_TOKEN } from '../select/select-injection-tokens'; +import { NbFormFieldControl, NbFormFieldControlConfig } from '../form-field/form-field-control'; +import { NbFocusMonitor } from '../cdk/a11y/a11y.module'; +import { NbScrollStrategies } from '../cdk/adapter/block-scroll-strategy-adapter'; +import { + NbActiveDescendantKeyManager, + NbActiveDescendantKeyManagerFactoryService, +} from '../cdk/a11y/descendant-key-manager'; +import { + NbSelectAppearance, + NbSelectCompareFunction, + nbSelectFormFieldControlConfigFactory, + NbSelectLabelComponent, +} from '../select/select.component'; + +/** + * Experimental component with autocomplete possibility. + * Could be changed without any prior notice. + * Use at your own risk. + * + * Style variables is fully inherited. + * Component's public API (`@Input()` and `@Output()`) works in a same way as NbSelectComponent. + */ +@Component({ + selector: 'nb-select-with-autocomplete', + templateUrl: './select-with-autocomplete.component.html', + styleUrls: ['./select-with-autocomplete.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => NbSelectWithAutocompleteComponent), + multi: true, + }, + { provide: NB_SELECT_INJECTION_TOKEN, useExisting: NbSelectWithAutocompleteComponent }, + { provide: NbFormFieldControl, useExisting: NbSelectWithAutocompleteComponent }, + { provide: NbFormFieldControlConfig, useFactory: nbSelectFormFieldControlConfigFactory }, + ], +}) +export class NbSelectWithAutocompleteComponent + implements OnChanges, AfterViewInit, AfterContentInit, OnDestroy, ControlValueAccessor, NbFormFieldControl +{ + /** + * Select size, available sizes: + * `tiny`, `small`, `medium` (default), `large`, `giant` + */ + @Input() size: NbComponentSize = 'medium'; + + /** + * Select status (adds specific styles): + * `basic`, `primary`, `info`, `success`, `warning`, `danger`, `control` + */ + @Input() status: NbComponentOrCustomStatus = 'basic'; + + /** + * Select shapes: `rectangle` (default), `round`, `semi-round` + */ + @Input() shape: NbComponentShape = 'rectangle'; + + /** + * Select appearances: `outline` (default), `filled`, `hero` + */ + @Input() appearance: NbSelectAppearance = 'outline'; + + /** + * Specifies class to be set on `nb-option`s container (`nb-option-list`) + * */ + @Input() optionsListClass: NgClass['ngClass']; + + /** + * Specifies class for the overlay panel with options + * */ + @Input() optionsPanelClass: string | string[]; + + /** + * Specifies width (in pixels) to be set on `nb-option`s container (`nb-option-list`) + * */ + @Input() + get optionsWidth(): number { + return this._optionsWidth ?? this.hostWidth; + } + set optionsWidth(value: number) { + this._optionsWidth = value; + } + protected _optionsWidth: number | undefined; + + /** + * Adds `outline` styles + */ + @Input() + @HostBinding('class.appearance-outline') + get outline(): boolean { + return this.appearance === 'outline'; + } + set outline(value: boolean) { + if (convertToBoolProperty(value)) { + this.appearance = 'outline'; + } + } + static ngAcceptInputType_outline: NbBooleanInput; + + /** + * Adds `filled` styles + */ + @Input() + @HostBinding('class.appearance-filled') + get filled(): boolean { + return this.appearance === 'filled'; + } + set filled(value: boolean) { + if (convertToBoolProperty(value)) { + this.appearance = 'filled'; + } + } + static ngAcceptInputType_filled: NbBooleanInput; + + /** + * Adds `hero` styles + */ + @Input() + @HostBinding('class.appearance-hero') + get hero(): boolean { + return this.appearance === 'hero'; + } + set hero(value: boolean) { + if (convertToBoolProperty(value)) { + this.appearance = 'hero'; + } + } + static ngAcceptInputType_hero: NbBooleanInput; + + /** + * Disables the select + */ + @Input() + get disabled(): boolean { + return !!this._disabled; + } + set disabled(value: boolean) { + this._disabled = convertToBoolProperty(value); + } + protected _disabled: boolean; + static ngAcceptInputType_disabled: NbBooleanInput; + + /** + * If set element will fill its container + */ + @Input() + @HostBinding('class.full-width') + get fullWidth(): boolean { + return this._fullWidth; + } + set fullWidth(value: boolean) { + this._fullWidth = convertToBoolProperty(value); + } + protected _fullWidth: boolean = false; + static ngAcceptInputType_fullWidth: NbBooleanInput; + + /** + * Renders select placeholder if nothing selected. + * */ + @Input() placeholder: string = ''; + + /** + * A function to compare option value with selected value. + * By default, values are compared with strict equality (`===`). + */ + @Input() + get compareWith(): NbSelectCompareFunction { + return this._compareWith; + } + set compareWith(fn: NbSelectCompareFunction) { + if (typeof fn !== 'function') { + return; + } + + this._compareWith = fn; + + if (this.selectionModel.length && this.canSelectValue()) { + this.setSelection(this.selected); + } + } + protected _compareWith: NbSelectCompareFunction = (v1: any, v2: any) => v1 === v2; + + /** + * Accepts selected item or array of selected items. + * */ + @Input() + set selected(value) { + this.writeValue(value); + } + get selected() { + return this.multiple ? this.selectionModel.map((o) => o.value) : this.selectionModel[0].value; + } + + /** + * Gives capability just write `multiple` over the element. + * */ + @Input() + get multiple(): boolean { + return this._multiple; + } + set multiple(value: boolean) { + this._multiple = convertToBoolProperty(value); + } + protected _multiple: boolean = false; + static ngAcceptInputType_multiple: NbBooleanInput; + + /** + * Determines options overlay offset (in pixels). + **/ + @Input() optionsOverlayOffset = 8; + + /** + * Determines options overlay scroll strategy. + **/ + @Input() scrollStrategy: NbScrollStrategies = 'block'; + + /** + * Experimental input. + * Could be changed without any prior notice. + * Use at your own risk. + * + * It replaces the button with input when the select is opened. + * That replacement provides a very basic API to implement options filtering functionality. + * Filtering itself isn't implemented inside select. + * So it should be implemented by the user. + */ + @Input() + set withOptionsAutocomplete(value: boolean) { + this._withOptionsAutocomplete = value; + this.updatePositionStrategy(); + this.updateCurrentKeyManager(); + + if (!value) { + this.resetAutocompleteInput(); + } + } + get withOptionsAutocomplete(): boolean { + return this._withOptionsAutocomplete; + } + protected _withOptionsAutocomplete: boolean = false; + + @HostBinding('class') + get additionalClasses(): string[] { + if (this.statusService.isCustomStatus(this.status)) { + return [this.statusService.getStatusClass(this.status)]; + } + return []; + } + + /** + * Will be emitted when selected value changes. + * */ + @Output() selectedChange: EventEmitter = new EventEmitter(); + @Output() selectOpen: EventEmitter = new EventEmitter(); + @Output() selectClose: EventEmitter = new EventEmitter(); + @Output() optionsAutocompleteInputChange: EventEmitter = new EventEmitter(); + + /** + * List of `NbOptionComponent`'s components passed as content. + * TODO maybe it would be better provide wrapper + * */ + @ContentChildren(NbOptionComponent, { descendants: true }) options: QueryList; + + /** + * Custom select label, will be rendered instead of default enumeration with coma. + * */ + @ContentChild(NbSelectLabelComponent) customLabel; + + /** + * NbCard with options content. + * */ + @ViewChild(NbPortalDirective) portal: NbPortalDirective; + + @ViewChild('selectButton', { read: ElementRef }) button: ElementRef | undefined; + @ViewChild('optionsAutocompleteInput', { read: ElementRef }) optionsAutocompleteInput: + | ElementRef + | undefined; + + /** + * Determines is select opened. + * */ + @HostBinding('class.open') + get isOpen(): boolean { + return this.ref && this.ref.hasAttached(); + } + + get isOptionsAutocompleteAllowed(): boolean { + return this.withOptionsAutocomplete && !this.multiple; + } + + get isOptionsAutocompleteInputShown(): boolean { + return this.isOptionsAutocompleteAllowed && this.isOpen; + } + + /** + * List of selected options. + * */ + selectionModel: NbOptionComponent[] = []; + + positionStrategy$: BehaviorSubject = new BehaviorSubject( + undefined, + ); + + /** + * Current overlay position because of we have to toggle overlayPosition + * in [ngClass] direction and this directive can use only string. + */ + overlayPosition: NbPosition = '' as NbPosition; + + protected ref: NbOverlayRef; + + protected triggerStrategy: NbTriggerStrategy; + + protected alive: boolean = true; + + protected destroy$ = new Subject(); + + protected currentKeyManager: ListKeyManager; + protected focusKeyManager: NbFocusKeyManager; + protected activeDescendantKeyManager: NbActiveDescendantKeyManager; + + /** + * If a user assigns value before content nb-options's rendered the value will be putted in this variable. + * And then applied after content rendered. + * Only the last value will be applied. + * */ + protected queue; + + /** + * Function passed through control value accessor to propagate changes. + * */ + protected onChange: Function = () => {}; + protected onTouched: Function = () => {}; + + /* + * @docs-private + **/ + status$ = new BehaviorSubject(this.status); + + /* + * @docs-private + **/ + size$ = new BehaviorSubject(this.size); + + /* + * @docs-private + **/ + focused$ = new BehaviorSubject(false); + + /* + * @docs-private + **/ + disabled$ = new BehaviorSubject(this.disabled); + + /* + * @docs-private + **/ + fullWidth$ = new BehaviorSubject(this.fullWidth); + + constructor( + @Inject(NB_DOCUMENT) protected document, + protected overlay: NbOverlayService, + protected hostRef: ElementRef, + protected positionBuilder: NbPositionBuilderService, + protected triggerStrategyBuilder: NbTriggerStrategyBuilderService, + protected cd: ChangeDetectorRef, + protected focusKeyManagerFactoryService: NbFocusKeyManagerFactoryService, + protected focusMonitor: NbFocusMonitor, + protected renderer: Renderer2, + protected zone: NgZone, + protected statusService: NbStatusService, + protected activeDescendantKeyManagerFactoryService: NbActiveDescendantKeyManagerFactoryService, + ) {} + + /** + * Determines is select hidden. + * */ + get isHidden(): boolean { + return !this.isOpen; + } + + /** + * Returns width of the select button. + * */ + get hostWidth(): number { + if (this.isOptionsAutocompleteInputShown) { + return this.optionsAutocompleteInput.nativeElement.getBoundingClientRect().width; + } + return this.button.nativeElement.getBoundingClientRect().width; + } + + get selectButtonClasses(): string[] { + const classes = []; + + if (!this.selectionModel.length) { + classes.push('placeholder'); + } + if (!this.selectionModel.length && !this.placeholder) { + classes.push('empty'); + } + if (this.isOpen) { + classes.push(this.overlayPosition); + } + + return classes; + } + + /** + * Content rendered in the label. + * */ + get selectionView() { + if (this.selectionModel.length > 1) { + return this.selectionModel.map((option: NbOptionComponent) => option.content).join(', '); + } + + return this.selectionModel[0]?.content ?? ''; + } + + ngOnChanges({ disabled, status, size, fullWidth }: SimpleChanges) { + if (disabled) { + this.disabled$.next(disabled.currentValue); + } + if (status) { + this.status$.next(status.currentValue); + } + if (size) { + this.size$.next(size.currentValue); + } + if (fullWidth) { + this.fullWidth$.next(this.fullWidth); + } + } + + ngAfterContentInit() { + this.options.changes + .pipe( + startWith(this.options), + filter(() => this.queue != null && this.canSelectValue()), + // Call 'writeValue' when current change detection run is finished. + // When writing is finished, change detection starts again, since + // microtasks queue is empty. + // Prevents ExpressionChangedAfterItHasBeenCheckedError. + switchMap((options: QueryList) => from(Promise.resolve(options))), + takeUntil(this.destroy$), + ) + .subscribe(() => this.writeValue(this.queue)); + } + + ngAfterViewInit() { + this.triggerStrategy = this.createTriggerStrategy(); + + this.subscribeOnButtonFocus(); + this.subscribeOnTriggers(); + this.subscribeOnOptionClick(); + + // TODO: #2254 + this.zone.runOutsideAngular(() => + setTimeout(() => { + this.renderer.addClass(this.hostRef.nativeElement, 'nb-transition'); + }), + ); + } + + ngOnDestroy() { + this.alive = false; + + this.destroy$.next(); + this.destroy$.complete(); + + if (this.ref) { + this.ref.dispose(); + } + if (this.triggerStrategy) { + this.triggerStrategy.destroy(); + } + } + + onAutocompleteInputChange(event: Event) { + this.optionsAutocompleteInputChange.emit((event.target as HTMLInputElement).value); + } + + show() { + if (this.shouldShow()) { + this.attachToOverlay(); + + this.positionStrategy$ + .pipe( + switchMap((positionStrategy) => positionStrategy.positionChange ?? EMPTY), + take(1), + takeUntil(this.destroy$), + ) + .subscribe(() => { + if (this.isOptionsAutocompleteInputShown) { + this.optionsAutocompleteInput.nativeElement.focus(); + } + this.setActiveOption(); + }); + + this.selectOpen.emit(); + + this.cd.markForCheck(); + } + } + + hide() { + if (this.isOpen) { + this.ref.detach(); + this.cd.markForCheck(); + this.selectClose.emit(); + + this.resetAutocompleteInput(); + } + } + + registerOnChange(fn: any): void { + this.onChange = fn; + } + + registerOnTouched(fn: any): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + this.cd.markForCheck(); + } + + writeValue(value): void { + if (!this.alive) { + return; + } + + if (this.canSelectValue()) { + this.setSelection(value); + if (this.selectionModel.length) { + this.queue = null; + } + } else { + this.queue = value; + } + } + + /** + * Selects option or clear all selected options if value is null. + * */ + protected handleOptionClick(option: NbOptionComponent) { + this.queue = null; + if (option.value == null) { + this.reset(); + } else { + this.selectOption(option); + } + + this.cd.markForCheck(); + } + + /** + * Deselect all selected options. + * */ + protected reset() { + this.selectionModel.forEach((option: NbOptionComponent) => option.deselect()); + this.selectionModel = []; + this.hide(); + this.focusButton(); + this.emitSelected(this.multiple ? [] : null); + } + + /** + * Determines how to select option as multiple or single. + * */ + protected selectOption(option: NbOptionComponent) { + if (this.multiple) { + this.handleMultipleSelect(option); + } else { + this.handleSingleSelect(option); + } + } + + /** + * Select single option. + * */ + protected handleSingleSelect(option: NbOptionComponent) { + const selected = this.selectionModel.pop(); + + if (selected && !this._compareWith(selected.value, option.value)) { + selected.deselect(); + } + + this.selectionModel = [option]; + option.select(); + this.hide(); + this.focusButton(); + this.emitSelected(option.value); + } + + /** + * Select for multiple options. + * */ + protected handleMultipleSelect(option: NbOptionComponent) { + if (option.selected) { + this.selectionModel = this.selectionModel.filter((s) => !this._compareWith(s.value, option.value)); + option.deselect(); + } else { + this.selectionModel.push(option); + option.select(); + } + + this.emitSelected(this.selectionModel.map((opt: NbOptionComponent) => opt.value)); + } + + protected attachToOverlay() { + if (!this.ref) { + this.createOverlay(); + this.subscribeOnPositionChange(); + this.createKeyManager(); + this.subscribeOnOverlayKeys(); + this.subscribeOnOptionsAutocompleteChange(); + } + + this.ref.attach(this.portal); + } + + protected setActiveOption() { + if (this.selectionModel.length && !this.selectionModel[0].hidden) { + this.currentKeyManager?.setActiveItem(this.selectionModel[0]); + } else { + this.currentKeyManager?.setFirstItemActive(); + } + } + + protected createOverlay() { + const scrollStrategy = this.createScrollStrategy(); + this.positionStrategy$.next(this.createPositionStrategy()); + this.ref = this.overlay.create({ + positionStrategy: this.positionStrategy$.value, + scrollStrategy, + panelClass: this.optionsPanelClass, + }); + } + + protected createKeyManager(): void { + this.activeDescendantKeyManager = this.activeDescendantKeyManagerFactoryService + .create(this.options) + .skipPredicate((option) => { + return this.isOptionHidden(option); + }); + + this.focusKeyManager = this.focusKeyManagerFactoryService + .create(this.options) + .withTypeAhead(200) + .skipPredicate((option) => { + return this.isOptionHidden(option); + }); + + this.updateCurrentKeyManager(); + } + + protected updateCurrentKeyManager() { + this.currentKeyManager?.setActiveItem(-1); + if (this.isOptionsAutocompleteAllowed) { + this.currentKeyManager = this.activeDescendantKeyManager; + } else { + this.currentKeyManager = this.focusKeyManager; + } + this.setActiveOption(); + } + + protected resetAutocompleteInput() { + this.optionsAutocompleteInput.nativeElement.value = this.selectionView; + this.optionsAutocompleteInputChange.emit(''); + } + + protected createPositionStrategy(): NbAdjustableConnectedPositionStrategy { + const element: ElementRef = this.isOptionsAutocompleteAllowed + ? this.optionsAutocompleteInput + : this.button; + return this.positionBuilder + .connectedTo(element) + .position(NbPosition.BOTTOM) + .offset(this.optionsOverlayOffset) + .adjustment(NbAdjustment.VERTICAL); + } + + protected updatePositionStrategy(): void { + if (this.ref) { + this.positionStrategy$.next(this.createPositionStrategy()); + this.ref.updatePositionStrategy(this.positionStrategy$.value); + if (this.isOpen) { + this.ref.updatePosition(); + } + } + } + + protected createScrollStrategy(): NbScrollStrategy { + return this.overlay.scrollStrategies[this.scrollStrategy](); + } + + protected createTriggerStrategy(): NbTriggerStrategy { + return this.triggerStrategyBuilder + .trigger(NbTrigger.CLICK) + .host(this.hostRef.nativeElement) + .container(() => this.getContainer()) + .build(); + } + + protected subscribeOnTriggers() { + this.triggerStrategy.show$.subscribe(() => this.show()); + this.triggerStrategy.hide$.pipe(filter(() => this.isOpen)).subscribe(($event: Event) => { + this.hide(); + if (!this.isClickedWithinComponent($event)) { + this.onTouched(); + } + }); + } + + protected subscribeOnPositionChange() { + this.positionStrategy$ + .pipe( + switchMap((positionStrategy) => positionStrategy.positionChange ?? EMPTY), + takeUntil(this.destroy$), + ) + .subscribe((position: NbPosition) => { + this.overlayPosition = position; + this.cd.detectChanges(); + }); + } + + protected subscribeOnOptionClick() { + /** + * If the user changes provided options list in the runtime we have to handle this + * and resubscribe on options selection changes event. + * Otherwise, the user will not be able to select new options. + * */ + this.options.changes + .pipe( + startWith(this.options), + switchMap((options: QueryList) => { + return merge(...options.map((option) => option.click)); + }), + takeUntil(this.destroy$), + ) + .subscribe((clickedOption: NbOptionComponent) => this.handleOptionClick(clickedOption)); + } + + protected subscribeOnOverlayKeys(): void { + this.ref + .keydownEvents() + .pipe( + filter(() => this.isOpen), + takeUntil(this.destroy$), + ) + .subscribe((event: KeyboardEvent) => { + if (event.keyCode === ESCAPE) { + this.hide(); + this.focusButton(); + } else if (event.keyCode === ENTER && this.isOptionsAutocompleteInputShown) { + event.preventDefault(); + const activeItem = this.currentKeyManager.activeItem; + if (activeItem) { + this.selectOption(activeItem); + } + } else { + this.currentKeyManager.onKeydown(event); + } + }); + + merge( + this.focusKeyManager.tabOut.pipe(filter(() => !this.isOptionsAutocompleteInputShown)), + this.activeDescendantKeyManager.tabOut.pipe(filter(() => this.isOptionsAutocompleteInputShown)), + ) + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.hide(); + this.onTouched(); + }); + } + + protected subscribeOnOptionsAutocompleteChange() { + this.optionsAutocompleteInputChange + .pipe( + observeOn(animationFrameScheduler), + filter(() => this.isOptionsAutocompleteInputShown), + takeUntil(this.destroy$), + ) + .subscribe(() => { + if (this.isOptionHidden(this.currentKeyManager.activeItem)) { + this.currentKeyManager.setFirstItemActive(); + } + }); + } + + protected subscribeOnButtonFocus() { + const buttonFocus$ = this.focusMonitor.monitor(this.button).pipe( + map((origin) => !!origin), + startWith(false), + finalize(() => this.focusMonitor.stopMonitoring(this.button)), + ); + + const filterInputFocus$ = this.focusMonitor.monitor(this.optionsAutocompleteInput).pipe( + map((origin) => !!origin), + startWith(false), + finalize(() => this.focusMonitor.stopMonitoring(this.button)), + ); + + combineLatest([buttonFocus$, filterInputFocus$]) + .pipe( + map(([buttonFocus, filterInputFocus]) => buttonFocus || filterInputFocus), + takeUntil(this.destroy$), + ) + .subscribe(this.focused$); + } + + protected getContainer() { + return ( + this.ref && + this.ref.hasAttached() && + >{ + location: { + nativeElement: this.ref.overlayElement, + }, + } + ); + } + + protected focusButton() { + /** + * Need to wrap with setTimeout + * because otherwise focus could be called + * when the component hasn't rerendered the button + * which was hidden by `isOptionsAutocompleteInputShown` property. + */ + setTimeout(() => { + this.button?.nativeElement?.focus(); + }); + } + + /** + * Propagate selected value. + * */ + protected emitSelected(selected) { + this.onChange(selected); + this.selectedChange.emit(selected); + } + + /** + * Set selected value in model. + * */ + protected setSelection(value) { + const isResetValue = value == null; + let safeValue = value; + + if (this.multiple) { + safeValue = value ?? []; + } + + const isArray: boolean = Array.isArray(safeValue); + + if (this.multiple && !isArray && !isResetValue) { + throw new Error("Can't assign single value if select is marked as multiple"); + } + if (!this.multiple && isArray) { + throw new Error("Can't assign array if select is not marked as multiple"); + } + + const previouslySelectedOptions = this.selectionModel; + this.selectionModel = []; + + if (this.multiple) { + safeValue.forEach((option) => this.selectValue(option)); + } else { + this.selectValue(safeValue); + } + + // find options which were selected before and trigger deselect + previouslySelectedOptions + .filter((option: NbOptionComponent) => !this.selectionModel.includes(option)) + .forEach((option: NbOptionComponent) => option.deselect()); + + this.cd.markForCheck(); + } + + /** + * Selects value. + * */ + protected selectValue(value) { + if (value == null) { + return; + } + + const corresponding = this.options.find((option: NbOptionComponent) => this._compareWith(option.value, value)); + + if (corresponding) { + corresponding.select(); + this.selectionModel.push(corresponding); + } + } + + protected shouldShow(): boolean { + return this.isHidden && this.options?.length > 0; + } + + /** + * Sets touched if focus moved outside of button and overlay, + * ignoring the case when focus moved to options overlay. + */ + trySetTouched() { + if (this.isHidden) { + this.onTouched(); + } + } + + protected isClickedWithinComponent($event: Event) { + return this.hostRef.nativeElement === $event.target || this.hostRef.nativeElement.contains($event.target as Node); + } + + protected canSelectValue(): boolean { + return !!(this.options && this.options.length); + } + + protected isOptionHidden(option: NbOptionComponent): boolean { + return option.hidden; + } + + @HostBinding('class.size-tiny') + get tiny(): boolean { + return this.size === 'tiny'; + } + @HostBinding('class.size-small') + get small(): boolean { + return this.size === 'small'; + } + @HostBinding('class.size-medium') + get medium(): boolean { + return this.size === 'medium'; + } + @HostBinding('class.size-large') + get large(): boolean { + return this.size === 'large'; + } + @HostBinding('class.size-giant') + get giant(): boolean { + return this.size === 'giant'; + } + @HostBinding('class.status-primary') + get primary(): boolean { + return this.status === 'primary'; + } + @HostBinding('class.status-info') + get info(): boolean { + return this.status === 'info'; + } + @HostBinding('class.status-success') + get success(): boolean { + return this.status === 'success'; + } + @HostBinding('class.status-warning') + get warning(): boolean { + return this.status === 'warning'; + } + @HostBinding('class.status-danger') + get danger(): boolean { + return this.status === 'danger'; + } + @HostBinding('class.status-basic') + get basic(): boolean { + return this.status === 'basic'; + } + @HostBinding('class.status-control') + get control(): boolean { + return this.status === 'control'; + } + @HostBinding('class.shape-rectangle') + get rectangle(): boolean { + return this.shape === 'rectangle'; + } + @HostBinding('class.shape-round') + get round(): boolean { + return this.shape === 'round'; + } + @HostBinding('class.shape-semi-round') + get semiRound(): boolean { + return this.shape === 'semi-round'; + } +} diff --git a/src/framework/theme/components/select-with-autocomplete/select-with-autocomplete.module.ts b/src/framework/theme/components/select-with-autocomplete/select-with-autocomplete.module.ts new file mode 100644 index 0000000000..f984932b28 --- /dev/null +++ b/src/framework/theme/components/select-with-autocomplete/select-with-autocomplete.module.ts @@ -0,0 +1,31 @@ +import { NgModule } from '@angular/core'; + +import { NbOverlayModule } from '../cdk/overlay/overlay.module'; +import { NbSharedModule } from '../shared/shared.module'; +import { NbInputModule } from '../input/input.module'; +import { NbCardModule } from '../card/card.module'; +import { NbButtonModule } from '../button/button.module'; +import { NbSelectWithAutocompleteComponent } from './select-with-autocomplete.component'; +import { NbOptionModule } from '../option/option-list.module'; +import { NbSelectModule } from '../select/select.module'; +import { NbIconModule } from '../icon/icon.module'; +import { NbFormFieldModule } from '../form-field/form-field.module'; + +const NB_SELECT_COMPONENTS = [NbSelectWithAutocompleteComponent]; + +@NgModule({ + imports: [ + NbSharedModule, + NbOverlayModule, + NbButtonModule, + NbInputModule, + NbCardModule, + NbIconModule, + NbOptionModule, + NbFormFieldModule, + NbSelectModule, + ], + exports: [...NB_SELECT_COMPONENTS, NbOptionModule, NbSelectModule], + declarations: [...NB_SELECT_COMPONENTS], +}) +export class NbSelectWithAutocompleteModule {} diff --git a/src/framework/theme/components/select-with-autocomplete/select-with-autocomplete.spec.ts b/src/framework/theme/components/select-with-autocomplete/select-with-autocomplete.spec.ts new file mode 100644 index 0000000000..33996c0f06 --- /dev/null +++ b/src/framework/theme/components/select-with-autocomplete/select-with-autocomplete.spec.ts @@ -0,0 +1,1354 @@ +/* + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { Component, ElementRef, EventEmitter, Input, Output, QueryList, ViewChild, ViewChildren } from '@angular/core'; +import { ComponentFixture, fakeAsync, flush, TestBed } from '@angular/core/testing'; +import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { RouterTestingModule } from '@angular/router/testing'; +import { from, zip, Subject } from 'rxjs'; +import createSpy = jasmine.createSpy; + +import { + NbSelectWithAutocompleteModule as NbSelectModule, + NbThemeModule, + NbOverlayContainerAdapter, + NB_DOCUMENT, + NbSelectWithAutocompleteComponent as NbSelectComponent, + NbLayoutModule, + NbOptionComponent, + NbOptionGroupComponent, + NbTriggerStrategyBuilderService, + NbFocusKeyManagerFactoryService, +} from '@nebular/theme'; +import { NbActiveDescendantKeyManagerFactoryService } from '../cdk/a11y/descendant-key-manager'; + +const eventMock = { preventDefault() {} } as Event; + +const TEST_GROUPS = [ + { + title: 'Group 1', + options: [ + { title: 'Option 1', value: 'Option 1' }, + { title: 'Option 2', value: 'Option 2' }, + { title: 'Option 3', value: 'Option 3' }, + ], + }, + { + title: 'Group 2', + options: [ + { title: 'Option 21', value: 'Option 21' }, + { title: 'Option 22', value: 'Option 22' }, + { title: 'Option 23', value: 'Option 23' }, + ], + }, + { + title: 'Group 3', + options: [ + { title: 'Option 31', value: 'Option 31' }, + { title: 'Option 32', value: 'Option 32' }, + { title: 'Option 33', value: 'Option 33' }, + ], + }, + { + title: 'Group 4', + options: [ + { title: 'Option 41', value: '' }, + { title: 'Option 42', value: '0' }, + { title: 'Option 43', value: 0 }, + { title: 'Option 44' }, + ], + }, +]; + +@Component({ + selector: 'nb-select-test', + template: ` + + + + + {{ selected.split('').reverse().join('') }} + + None + + {{ option.title }} + + + + + `, +}) +export class NbSelectTestComponent { + @Input() selected: any = null; + @Input() multiple: boolean; + @Input() customLabel: boolean; + @Output() selectedChange: EventEmitter = new EventEmitter(); + @ViewChildren(NbOptionComponent) options: QueryList>; + groups = TEST_GROUPS; + opened = false; +} + +@Component({ + template: ` + + + + a + b + c + + + + `, +}) +export class BasicSelectTestComponent {} + +@Component({ + template: ` + + + + {{ option }} + + + + `, +}) +export class NbSelectWithOptionsObjectsComponent { + @Input() compareFn = (o1: any, o2: any) => JSON.stringify(o1) === JSON.stringify(o2); + @Input() selected = { id: 2 }; + @Input() options = [{ id: 1 }, { id: 2 }, { id: 3 }]; + + @ViewChildren(NbOptionComponent) optionComponents: QueryList; +} + +@Component({ + template: ` + + + + {{ option }} + + + + `, +}) +export class NbSelectWithInitiallySelectedOptionComponent { + @Input() selected = 1; + @Input() options = [1, 2, 3]; +} + +@Component({ + template: ` + + + + {{ option }} + + + + `, +}) +export class NbReactiveFormSelectComponent { + options: number[] = [1]; + showSelect: boolean = true; + formControl: FormControl = new FormControl(); + + @ViewChild(NbSelectComponent) selectComponent: NbSelectComponent; + @ViewChildren(NbOptionComponent) optionComponents: QueryList>; +} + +@Component({ + template: ` + + + + {{ option }} + + + + `, +}) +export class NbNgModelSelectComponent { + options: number[] = [1]; + selectedValue: number = null; + + @ViewChild(NbOptionComponent) optionComponent: NbOptionComponent; +} + +@Component({ + template: ` + + + + No value option + undefined value + undefined value + false value + 0 value + empty string value + NaN value + truthy value + + + + `, +}) +export class NbSelectWithFalsyOptionValuesComponent { + nanValue = NaN; + + @ViewChildren(NbOptionComponent) options: QueryList>; + @ViewChildren(NbOptionComponent, { read: ElementRef }) optionElements: QueryList>; + + get noValueOption(): NbOptionComponent { + return this.options.toArray()[0]; + } + get noValueOptionElement(): ElementRef { + return this.optionElements.toArray()[0]; + } + get nullOption(): NbOptionComponent { + return this.options.toArray()[1]; + } + get nullOptionElement(): ElementRef { + return this.optionElements.toArray()[1]; + } + get undefinedOption(): NbOptionComponent { + return this.options.toArray()[2]; + } + get undefinedOptionElement(): ElementRef { + return this.optionElements.toArray()[2]; + } + get falseOption(): NbOptionComponent { + return this.options.toArray()[3]; + } + get falseOptionElement(): ElementRef { + return this.optionElements.toArray()[3]; + } + get zeroOption(): NbOptionComponent { + return this.options.toArray()[4]; + } + get zeroOptionElement(): ElementRef { + return this.optionElements.toArray()[4]; + } + get emptyStringOption(): NbOptionComponent { + return this.options.toArray()[5]; + } + get emptyStringOptionElement(): ElementRef { + return this.optionElements.toArray()[5]; + } + get nanOption(): NbOptionComponent { + return this.options.toArray()[6]; + } + get nanOptionElement(): ElementRef { + return this.optionElements.toArray()[6]; + } + get truthyOption(): NbOptionComponent { + return this.options.toArray()[7]; + } + get truthyOptionElement(): ElementRef { + return this.optionElements.toArray()[7]; + } +} + +@Component({ + template: ` + + + + No value option + undefined value + undefined value + false value + 0 value + empty string value + NaN value + truthy value + + + + `, +}) +export class NbMultipleSelectWithFalsyOptionValuesComponent extends NbSelectWithFalsyOptionValuesComponent {} + +@Component({ + template: ` + + + + + 1 + + + + + `, +}) +export class NbOptionDisabledTestComponent { + optionGroupDisabled = false; + optionDisabled = false; + + @ViewChild(NbSelectComponent) selectComponent: NbSelectComponent; + @ViewChild(NbOptionGroupComponent) optionGroupComponent: NbOptionGroupComponent; + @ViewChild(NbOptionComponent) optionComponent: NbOptionComponent; +} + +describe('Component: NbSelectComponent', () => { + let fixture: ComponentFixture; + let overlayContainerService: NbOverlayContainerAdapter; + let overlayContainer: HTMLElement; + let document: Document; + let select: NbSelectComponent; + + const setSelectedAndOpen = (selected) => { + fixture.componentInstance.selected = selected; + fixture.detectChanges(); + select.show(); + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + RouterTestingModule.withRoutes([]), + FormsModule, + ReactiveFormsModule, + NbThemeModule.forRoot(), + NbLayoutModule, + NbSelectModule, + ], + declarations: [ + NbSelectTestComponent, + NbSelectWithOptionsObjectsComponent, + NbSelectWithInitiallySelectedOptionComponent, + NbReactiveFormSelectComponent, + NbNgModelSelectComponent, + ], + }); + + fixture = TestBed.createComponent(NbSelectTestComponent); + overlayContainerService = TestBed.inject(NbOverlayContainerAdapter); + document = TestBed.inject(NB_DOCUMENT); + select = fixture.debugElement.query(By.directive(NbSelectComponent)).componentInstance; + + overlayContainer = document.createElement('div'); + overlayContainerService.setContainer(overlayContainer); + + fixture.detectChanges(); + }); + + afterEach(() => { + select.hide(); + overlayContainerService.clearContainer(); + }); + + it('should render passed item as selected', () => { + setSelectedAndOpen('Option 23'); + const selected = overlayContainer.querySelector('nb-option.selected'); + + expect(selected).toBeTruthy(); + expect(selected.textContent).toContain('Option 23'); + }); + + it('should render passed items as selected', () => { + select.multiple = true; + setSelectedAndOpen(['Option 1', 'Option 21', 'Option 31']); + const selected = overlayContainer.querySelectorAll('nb-option.selected'); + + expect(selected.length).toBe(3); + expect(selected[0].textContent).toContain('Option 1'); + expect(selected[1].textContent).toContain('Option 21'); + expect(selected[2].textContent).toContain('Option 31'); + }); + + it('should fire selectedChange item when selection changes', (done) => { + setSelectedAndOpen('Option 1'); + + fixture.componentInstance.selectedChange.subscribe((selection) => { + expect(selection).toBe('Option 21'); + done(); + }); + + const option = overlayContainer.querySelectorAll('nb-option')[4]; + option.dispatchEvent(new Event('click')); + }); + + it('should fire selectedChange items when selecting multiple one by one', (done) => { + select.multiple = true; + setSelectedAndOpen([]); + + zip( + from([['Option 2'], ['Option 2', 'Option 21'], ['Option 2', 'Option 21', 'Option 23']]), + fixture.componentInstance.selectedChange, + ).subscribe(([expected, real]) => expect(real).toEqual(expected), null, done); + + const option1 = overlayContainer.querySelectorAll('nb-option')[2]; + const option2 = overlayContainer.querySelectorAll('nb-option')[4]; + const option3 = overlayContainer.querySelectorAll('nb-option')[6]; + option1.dispatchEvent(new Event('click')); + option2.dispatchEvent(new Event('click')); + option3.dispatchEvent(new Event('click')); + }); + + it('should deselect item when clicking on reselect item', () => { + setSelectedAndOpen('Option 1'); + + const option = overlayContainer.querySelector('nb-option'); + option.dispatchEvent(new Event('click')); + + expect(overlayContainer.querySelectorAll('nb-option.selected').length).toBe(0); + }); + + it('should deselect all items when clicking on reset item in multiple select', () => { + select.multiple = true; + setSelectedAndOpen(['Option 1', 'Option 2']); + + const option = overlayContainer.querySelector('nb-option'); + option.dispatchEvent(new Event('click')); + + expect(overlayContainer.querySelectorAll('nb-option.selected').length).toBe(0); + }); + + it('should emit selectionChange with empty array when reset option selected in multiple select', () => { + select.multiple = true; + setSelectedAndOpen(['Option 1', 'Option 2']); + + const selectionChangeSpy = createSpy('selectionChangeSpy'); + select.selectedChange.subscribe(selectionChangeSpy); + + const option = overlayContainer.querySelector('nb-option'); + option.dispatchEvent(new Event('click')); + + expect(selectionChangeSpy).toHaveBeenCalledWith([]); + }); + + it('should emit selectionChange with null when reset option selected in single select', () => { + setSelectedAndOpen('Option 1'); + + const selectionChangeSpy = createSpy('selectionChangeSpy'); + select.selectedChange.subscribe(selectionChangeSpy); + + const option = overlayContainer.querySelector('nb-option'); + option.dispatchEvent(new Event('click')); + + expect(selectionChangeSpy).toHaveBeenCalledWith(null); + }); + + it('should deselect only clicked item in multiple select', () => { + select.multiple = true; + setSelectedAndOpen(['Option 1', 'Option 2']); + + const option = overlayContainer.querySelectorAll('nb-option')[1]; + option.dispatchEvent(new Event('click')); + + fixture.detectChanges(); + + const selected = overlayContainer.querySelectorAll('nb-option.selected'); + expect(selected.length).toBe(1); + expect(selected[0].textContent).toContain('Option 2'); + }); + + it('should render placeholder when nothing selected', () => { + select.multiple = true; + setSelectedAndOpen([]); + + const button = fixture.nativeElement.querySelector('button'); + expect(button.textContent).toContain('This is test select component'); + }); + + it('should render default label when something selected', () => { + setSelectedAndOpen('Option 1'); + + const button = fixture.nativeElement.querySelector('button'); + expect(button.textContent).toContain('Option 1'); + }); + + it('should render custom label when something selected and custom label provided', fakeAsync(() => { + fixture.componentInstance.customLabel = true; + fixture.componentInstance.selected = 'Option 1'; + fixture.detectChanges(); + flush(); + fixture.detectChanges(); + const button = fixture.nativeElement.querySelector('button'); + expect(button.textContent).toContain('1 noitpO'); + })); + + it('should select initially specified value without errors', fakeAsync(() => { + const selectFixture = TestBed.createComponent(NbSelectWithInitiallySelectedOptionComponent); + selectFixture.detectChanges(); + flush(); + selectFixture.detectChanges(); + + const selectedOption = selectFixture.debugElement + .query(By.directive(NbSelectComponent)) + .componentInstance.options.find((o) => o.selected); + + expect(selectedOption.value).toEqual(selectFixture.componentInstance.selected); + const selectButton = selectFixture.nativeElement.querySelector('nb-select-with-autocomplete button') as HTMLElement; + expect(selectButton.textContent).toEqual(selectedOption.value.toString()); + })); + + it('should use compareWith function to compare values', fakeAsync(() => { + const selectFixture = TestBed.createComponent(NbSelectWithOptionsObjectsComponent); + const testComponent = selectFixture.componentInstance; + selectFixture.detectChanges(); + flush(); + selectFixture.detectChanges(); + + const selectedOption = testComponent.optionComponents.find((o) => o.selected); + expect(selectedOption.value).toEqual({ id: 2 }); + })); + + it('should ignore selection change if destroyed', fakeAsync(() => { + const selectFixture = TestBed.createComponent(NbReactiveFormSelectComponent); + const testSelectComponent = selectFixture.componentInstance; + selectFixture.detectChanges(); + flush(); + + const setSelectionSpy = spyOn(testSelectComponent.selectComponent as any, 'setSelection').and.callThrough(); + testSelectComponent.showSelect = false; + selectFixture.detectChanges(); + + expect(() => testSelectComponent.formControl.setValue(1)).not.toThrow(); + expect(setSelectionSpy).not.toHaveBeenCalled(); + })); + + it('should select option set through formControl binding', fakeAsync(() => { + const selectFixture = TestBed.createComponent(NbReactiveFormSelectComponent); + const testComponent = selectFixture.componentInstance; + selectFixture.detectChanges(); + flush(); + + const optionSelectSpy = spyOn(testComponent.optionComponents.first, 'select').and.callThrough(); + + expect(testComponent.optionComponents.first.selected).toEqual(false); + + testComponent.formControl.setValue(1); + selectFixture.detectChanges(); + + expect(testComponent.optionComponents.first.selected).toEqual(true); + expect(optionSelectSpy).toHaveBeenCalledTimes(1); + })); + + it('should select option set through select "selected" binding', fakeAsync(() => { + const selectFixture = TestBed.createComponent(NbSelectTestComponent); + const testComponent = selectFixture.componentInstance; + selectFixture.detectChanges(); + flush(); + + const optionToSelect = testComponent.options.find((o) => o.value != null); + const optionSelectSpy = spyOn(optionToSelect, 'select').and.callThrough(); + + expect(optionToSelect.selected).toEqual(false); + + testComponent.selected = optionToSelect.value; + selectFixture.detectChanges(); + + expect(optionToSelect.selected).toEqual(true); + expect(optionSelectSpy).toHaveBeenCalledTimes(1); + })); + + it('should select option set through ngModel binding', fakeAsync(() => { + const selectFixture = TestBed.createComponent(NbNgModelSelectComponent); + const testComponent = selectFixture.componentInstance; + selectFixture.detectChanges(); + + const optionToSelect = testComponent.optionComponent; + const optionSelectSpy = spyOn(optionToSelect, 'select').and.callThrough(); + + expect(optionToSelect.selected).toEqual(false); + + testComponent.selectedValue = optionToSelect.value; + selectFixture.detectChanges(); + // need to call flush because NgModelDirective updates value on + // resolvedPromise.then + flush(); + selectFixture.detectChanges(); + + expect(optionToSelect.selected).toEqual(true); + expect(optionSelectSpy).toHaveBeenCalledTimes(1); + })); + + it('should unselect previously selected option', fakeAsync(() => { + const selectFixture = TestBed.createComponent(NbSelectTestComponent); + const testSelectComponent = selectFixture.componentInstance; + testSelectComponent.selected = TEST_GROUPS[0].options[0].value; + selectFixture.detectChanges(); + flush(); + selectFixture.detectChanges(); + + const selectedOption: NbOptionComponent = testSelectComponent.options.find((o) => o.selected); + const selectionChangeSpy = createSpy('selectionChangeSpy'); + selectedOption.selectionChange.subscribe(selectionChangeSpy); + + testSelectComponent.selected = TEST_GROUPS[0].options[1].value; + selectFixture.detectChanges(); + + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + expect(selectedOption.selected).toEqual(false); + })); + + it('should not deselect option if option stays selected', fakeAsync(() => { + const selectFixture = TestBed.createComponent(NbSelectTestComponent); + const testSelectComponent = selectFixture.componentInstance; + testSelectComponent.selected = TEST_GROUPS[0].options[0].value; + selectFixture.detectChanges(); + flush(); + selectFixture.detectChanges(); + + const selectedOption: NbOptionComponent = testSelectComponent.options.find((o) => o.selected); + const selectionChangeSpy = spyOn(selectedOption, 'deselect'); + + testSelectComponent.selected = selectedOption.value; + selectFixture.detectChanges(); + + expect(selectionChangeSpy).not.toHaveBeenCalled(); + })); + + it(`should not call dispose on uninitialized resources`, () => { + const selectFixture = new NbSelectComponent(null, null, null, null, null, null, null, null, null, null, null, null); + expect(() => selectFixture.ngOnDestroy()).not.toThrow(); + }); + + it(`should has 'empty' class when has no placeholder and text`, () => { + const selectFixture = TestBed.createComponent(NbSelectComponent); + selectFixture.detectChanges(); + const button = selectFixture.debugElement.query(By.css('button')); + + expect(button.classes.empty).toEqual(true); + }); + + it(`should set overlay width same as button inside select`, () => { + const selectFixture = TestBed.createComponent(NbSelectComponent); + const selectComponent = selectFixture.componentInstance; + selectFixture.detectChanges(); + + const selectElement: HTMLElement = selectFixture.nativeElement; + const buttonElement: HTMLElement = selectElement.querySelector('button'); + + selectElement.style.padding = '1px'; + + expect(selectComponent.hostWidth).not.toEqual(selectElement.offsetWidth); + expect(selectComponent.hostWidth).toEqual(buttonElement.offsetWidth); + }); + + it('should not open when disabled and button clicked', fakeAsync(() => { + const selectFixture = TestBed.createComponent(NbSelectComponent); + selectFixture.componentInstance.disabled = true; + selectFixture.detectChanges(); + const selectButton: HTMLElement = selectFixture.debugElement.query(By.css('button')).nativeElement; + + selectButton.click(); + flush(); + fixture.detectChanges(); + + expect(selectFixture.componentInstance.isOpen).toBeFalsy(); + })); + + it('should not open when disabled and toggle icon clicked', fakeAsync(() => { + const selectFixture = TestBed.createComponent(NbSelectComponent); + selectFixture.componentInstance.disabled = true; + selectFixture.detectChanges(); + const selectToggleIcon: HTMLElement = selectFixture.debugElement.query(By.css('nb-icon')).nativeElement; + + selectToggleIcon.click(); + flush(); + fixture.detectChanges(); + + expect(selectFixture.componentInstance.isOpen).toBeFalsy(); + })); + + it('should mark touched when select button loose focus and select closed', fakeAsync(() => { + const touchedSpy = jasmine.createSpy('touched spy'); + + const selectFixture = TestBed.createComponent(NbSelectComponent); + const selectComponent: NbSelectComponent = selectFixture.componentInstance; + selectFixture.detectChanges(); + flush(); + + selectComponent.registerOnTouched(touchedSpy); + selectFixture.debugElement.query(By.css('.select-button')).triggerEventHandler('blur', {}); + expect(touchedSpy).toHaveBeenCalledTimes(1); + })); + + it('should not mark touched when select button loose focus and select open', fakeAsync(() => { + const touchedSpy = jasmine.createSpy('touched spy'); + + const selectFixture = TestBed.createComponent(NbSelectTestComponent); + select = selectFixture.debugElement.query(By.directive(NbSelectComponent)).componentInstance; + selectFixture.detectChanges(); + flush(); + + select.registerOnTouched(touchedSpy); + select.show(); + selectFixture.debugElement.query(By.css('.select-button')).triggerEventHandler('blur', {}); + expect(touchedSpy).not.toHaveBeenCalled(); + })); + + it('should emit open event after opening and close event after closing', fakeAsync(() => { + const selectFixture = TestBed.createComponent(NbSelectTestComponent); + select = selectFixture.debugElement.query(By.directive(NbSelectComponent)).componentInstance; + + selectFixture.detectChanges(); + expect(selectFixture.componentInstance.opened).toBe(false); + select.show(); + selectFixture.detectChanges(); + flush(); + expect(selectFixture.componentInstance.opened).toBe(true); + select.hide(); + selectFixture.detectChanges(); + flush(); + expect(selectFixture.componentInstance.opened).toBe(false); + })); +}); + +describe('NbSelectComponent - falsy values', () => { + let fixture: ComponentFixture; + let testComponent: NbSelectWithFalsyOptionValuesComponent; + let select: NbSelectComponent; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [RouterTestingModule.withRoutes([]), NbThemeModule.forRoot(), NbLayoutModule, NbSelectModule], + declarations: [NbSelectWithFalsyOptionValuesComponent, NbMultipleSelectWithFalsyOptionValuesComponent], + }); + + fixture = TestBed.createComponent(NbSelectWithFalsyOptionValuesComponent); + testComponent = fixture.componentInstance; + select = fixture.debugElement.query(By.directive(NbSelectComponent)).componentInstance; + + fixture.detectChanges(); + flush(); + })); + + it('should clean selection when selected option does not have a value', fakeAsync(() => { + select.selected = testComponent.truthyOption.value; + fixture.detectChanges(); + + testComponent.noValueOption.onClick(eventMock); + fixture.detectChanges(); + + expect(select.selectionModel.length).toEqual(0); + })); + + it('should clean selection when selected option has null value', fakeAsync(() => { + select.selected = testComponent.truthyOption.value; + fixture.detectChanges(); + + testComponent.nullOption.onClick(eventMock); + fixture.detectChanges(); + + expect(select.selectionModel.length).toEqual(0); + })); + + it('should clean selection when selected option has undefined value', fakeAsync(() => { + select.selected = testComponent.truthyOption.value; + fixture.detectChanges(); + + testComponent.undefinedOption.onClick(eventMock); + fixture.detectChanges(); + + expect(select.selectionModel.length).toEqual(0); + })); + + it('should not reset selection when selected option has false value', fakeAsync(() => { + select.selected = testComponent.truthyOption.value; + fixture.detectChanges(); + + testComponent.falseOption.onClick(eventMock); + fixture.detectChanges(); + + expect(select.selectionModel.length).toEqual(1); + })); + + it('should not reset selection when selected option has zero value', fakeAsync(() => { + select.selected = testComponent.truthyOption.value; + fixture.detectChanges(); + + testComponent.zeroOption.onClick(eventMock); + fixture.detectChanges(); + + expect(select.selectionModel.length).toEqual(1); + })); + + it('should not reset selection when selected option has empty string value', fakeAsync(() => { + select.selected = testComponent.truthyOption.value; + fixture.detectChanges(); + + testComponent.emptyStringOption.onClick(eventMock); + fixture.detectChanges(); + + expect(select.selectionModel.length).toEqual(1); + })); + + it('should not reset selection when selected option has NaN value', fakeAsync(() => { + select.selected = testComponent.truthyOption.value; + fixture.detectChanges(); + + testComponent.nanOption.onClick(eventMock); + fixture.detectChanges(); + + expect(select.selectionModel.length).toEqual(1); + })); + + it('should set class if fullWidth input set to true', () => { + select.fullWidth = true; + fixture.detectChanges(); + + const button = fixture.debugElement.query(By.directive(NbSelectComponent)); + expect(button.classes['full-width']).toEqual(true); + }); + + describe('multiple', () => { + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(NbMultipleSelectWithFalsyOptionValuesComponent); + testComponent = fixture.componentInstance; + select = fixture.debugElement.query(By.directive(NbSelectComponent)).componentInstance; + + fixture.detectChanges(); + flush(); + select.show(); + fixture.detectChanges(); + })); + + it('should not render checkbox on options with reset values', () => { + expect(testComponent.noValueOptionElement.nativeElement.querySelector('nb-checkbox')).toEqual(null); + expect(testComponent.nullOptionElement.nativeElement.querySelector('nb-checkbox')).toEqual(null); + expect(testComponent.undefinedOptionElement.nativeElement.querySelector('nb-checkbox')).toEqual(null); + }); + + it('should render checkbox on options with falsy non-reset values', () => { + expect(testComponent.falseOptionElement.nativeElement.querySelector('nb-checkbox')).not.toEqual(null); + expect(testComponent.zeroOptionElement.nativeElement.querySelector('nb-checkbox')).not.toEqual(null); + expect(testComponent.emptyStringOptionElement.nativeElement.querySelector('nb-checkbox')).not.toEqual(null); + expect(testComponent.nanOptionElement.nativeElement.querySelector('nb-checkbox')).not.toEqual(null); + expect(testComponent.truthyOptionElement.nativeElement.querySelector('nb-checkbox')).not.toEqual(null); + }); + }); + + it('should select initial falsy value', fakeAsync(() => { + fixture = TestBed.createComponent(NbSelectWithFalsyOptionValuesComponent); + testComponent = fixture.componentInstance; + select = fixture.debugElement.query(By.directive(NbSelectComponent)).componentInstance; + + select.selected = ''; + fixture.detectChanges(); + flush(); + + expect(select.selectionModel[0]).toEqual(testComponent.emptyStringOption); + expect(testComponent.emptyStringOption.selected).toEqual(true); + })); +}); + +describe('NbSelectComponent - Triggers', () => { + let fixture: ComponentFixture; + let selectComponent: NbSelectComponent; + let triggerBuilderStub; + let showTriggerStub: Subject; + let hideTriggerStub: Subject; + + beforeEach(fakeAsync(() => { + showTriggerStub = new Subject(); + hideTriggerStub = new Subject(); + triggerBuilderStub = { + trigger() { + return this; + }, + host() { + return this; + }, + container() { + return this; + }, + build() { + return { show$: showTriggerStub, hide$: hideTriggerStub, destroy() {} }; + }, + }; + + TestBed.configureTestingModule({ + imports: [RouterTestingModule.withRoutes([]), NbThemeModule.forRoot(), NbLayoutModule, NbSelectModule], + declarations: [BasicSelectTestComponent], + }); + TestBed.overrideProvider(NbTriggerStrategyBuilderService, { useValue: triggerBuilderStub }); + + fixture = TestBed.createComponent(BasicSelectTestComponent); + fixture.detectChanges(); + flush(); + + selectComponent = fixture.debugElement.query(By.directive(NbSelectComponent)).componentInstance; + })); + + it('should mark touched if clicked outside of overlay and select', fakeAsync(() => { + const touchedSpy = jasmine.createSpy('touched spy'); + selectComponent.registerOnTouched(touchedSpy); + + const elementOutsideSelect = fixture.debugElement.query(By.css('nb-layout')).nativeElement; + selectComponent.show(); + fixture.detectChanges(); + + hideTriggerStub.next({ target: elementOutsideSelect } as unknown as Event); + + expect(touchedSpy).toHaveBeenCalledTimes(1); + })); + + it('should not mark touched if clicked on the select button', fakeAsync(() => { + const touchedSpy = jasmine.createSpy('touched spy'); + selectComponent.registerOnTouched(touchedSpy); + + const selectButton = fixture.debugElement.query(By.css('.select-button')).nativeElement; + selectComponent.show(); + fixture.detectChanges(); + + hideTriggerStub.next({ target: selectButton } as unknown as Event); + + expect(touchedSpy).not.toHaveBeenCalled(); + })); +}); + +describe('NbSelectComponent - Key manager', () => { + let fixture: ComponentFixture; + let selectComponent: NbSelectComponent; + let tabOutStub: Subject; + let keyManagerFactoryStub; + let keyManagerStub; + + beforeEach(fakeAsync(() => { + tabOutStub = new Subject(); + keyManagerStub = { + withTypeAhead() { + return this; + }, + setActiveItem() {}, + setFirstItemActive() {}, + onKeydown() {}, + skipPredicate() { + return this; + }, + tabOut: tabOutStub, + }; + keyManagerFactoryStub = { + create() { + return keyManagerStub; + }, + }; + + TestBed.configureTestingModule({ + imports: [RouterTestingModule.withRoutes([]), NbThemeModule.forRoot(), NbLayoutModule, NbSelectModule], + declarations: [BasicSelectTestComponent], + }); + TestBed.overrideProvider(NbFocusKeyManagerFactoryService, { useValue: keyManagerFactoryStub }); + TestBed.overrideProvider(NbActiveDescendantKeyManagerFactoryService, { useValue: keyManagerFactoryStub }); + + fixture = TestBed.createComponent(BasicSelectTestComponent); + fixture.detectChanges(); + flush(); + + selectComponent = fixture.debugElement.query(By.directive(NbSelectComponent)).componentInstance; + })); + + it('should mark touched when tabbing out from options list', fakeAsync(() => { + selectComponent.show(); + fixture.detectChanges(); + + const touchedSpy = jasmine.createSpy('touched spy'); + selectComponent.registerOnTouched(touchedSpy); + tabOutStub.next(); + flush(); + expect(touchedSpy).toHaveBeenCalledTimes(1); + })); +}); + +describe('NbOptionComponent', () => { + let fixture: ComponentFixture; + let testSelectComponent: NbReactiveFormSelectComponent; + let option: NbOptionComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + RouterTestingModule.withRoutes([]), + FormsModule, + ReactiveFormsModule, + NbThemeModule.forRoot(), + NbLayoutModule, + NbSelectModule, + ], + declarations: [NbNgModelSelectComponent, NbSelectTestComponent, NbReactiveFormSelectComponent], + }); + + fixture = TestBed.createComponent(NbReactiveFormSelectComponent); + testSelectComponent = fixture.componentInstance; + fixture.detectChanges(); + option = testSelectComponent.optionComponents.first; + }); + + it('should ignore selection change if destroyed', fakeAsync(() => { + const selectionChangeSpy = createSpy('selectionChangeSpy'); + option.selectionChange.subscribe(selectionChangeSpy); + + expect(option.selected).toEqual(false); + testSelectComponent.showSelect = false; + fixture.detectChanges(); + + expect((option as any).alive).toEqual(false); + + option.select(); + + expect(option.selected).toEqual(false); + expect(selectionChangeSpy).not.toHaveBeenCalled(); + })); + + it('should not emit selection change when already selected', fakeAsync(() => { + option.select(); + fixture.detectChanges(); + expect(option.selected).toEqual(true); + + const selectionChangeSpy = createSpy('selectionChangeSpy'); + option.selectionChange.subscribe(selectionChangeSpy); + option.select(); + + expect(option.selected).toEqual(true); + expect(selectionChangeSpy).not.toHaveBeenCalled(); + })); + + it('should emit selection change when deselected', fakeAsync(() => { + option.select(); + fixture.detectChanges(); + expect(option.selected).toEqual(true); + + const selectionChangeSpy = createSpy('selectionChangeSpy'); + option.selectionChange.subscribe(selectionChangeSpy); + option.deselect(); + + expect(option.selected).toEqual(false); + expect(selectionChangeSpy).toHaveBeenCalledTimes(1); + })); +}); + +describe('NbOptionComponent disabled', () => { + let fixture: ComponentFixture; + let testComponent: NbOptionDisabledTestComponent; + let selectComponent: NbSelectComponent; + let optionGroupComponent: NbOptionGroupComponent; + let optionComponent: NbOptionComponent; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [RouterTestingModule.withRoutes([]), NbThemeModule.forRoot(), NbLayoutModule, NbSelectModule], + declarations: [NbOptionDisabledTestComponent], + }); + + fixture = TestBed.createComponent(NbOptionDisabledTestComponent); + testComponent = fixture.componentInstance; + fixture.detectChanges(); + flush(); + + selectComponent = testComponent.selectComponent; + optionGroupComponent = testComponent.optionGroupComponent; + optionComponent = testComponent.optionComponent; + })); + + it('should has disabled attribute if disabled set to true', () => { + selectComponent.show(); + testComponent.optionDisabled = true; + fixture.detectChanges(); + + const option = fixture.debugElement.query(By.directive(NbOptionComponent)); + expect(option.attributes.disabled).toEqual(''); + }); + + it('should has disabled attribute if group disabled set to true', fakeAsync(() => { + selectComponent.show(); + testComponent.optionGroupDisabled = true; + fixture.detectChanges(); + flush(); + fixture.detectChanges(); + + const option = fixture.debugElement.query(By.directive(NbOptionComponent)); + expect(option.attributes.disabled).toEqual(''); + })); +}); + +describe('NbSelect - dynamic options', () => { + let fixture: ComponentFixture; + let testComponent: NbReactiveFormSelectComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + RouterTestingModule.withRoutes([]), + FormsModule, + ReactiveFormsModule, + NbThemeModule.forRoot(), + NbLayoutModule, + NbSelectModule, + ], + declarations: [NbReactiveFormSelectComponent], + }); + + fixture = TestBed.createComponent(NbReactiveFormSelectComponent); + testComponent = fixture.componentInstance; + }); + + describe('Set value from queue', () => { + let selectComponent: NbSelectComponent; + + beforeEach(() => { + // Force select to cache the value as there is no options to select. + testComponent.options = []; + testComponent.formControl = new FormControl(1); + fixture.detectChanges(); + + selectComponent = fixture.debugElement.query(By.directive(NbSelectComponent)).componentInstance; + }); + + it('should set value from queue when options added dynamically (after change detection run)', fakeAsync(() => { + expect(selectComponent.selectionModel.length).toEqual(0); + + testComponent.options = [1]; + fixture.detectChanges(); + flush(); + fixture.detectChanges(); + + expect(selectComponent.selectionModel[0]).toEqual(testComponent.optionComponents.first); + })); + + it('should set value from queue when options change', fakeAsync(() => { + testComponent.options = [0]; + fixture.detectChanges(); + flush(); + fixture.detectChanges(); + + expect(selectComponent.selectionModel.length).toEqual(0); + + testComponent.options.push(1); + fixture.detectChanges(); + flush(); + fixture.detectChanges(); + + expect(selectComponent.selectionModel[0]).toEqual(testComponent.optionComponents.last); + })); + }); + + describe('Clear queue after value set', () => { + /* + We can ensure queue is clean by spying on `writeValue` calls on select. It will be called only if options + change and queue has a value. + */ + + it('should clear queue after option selected by click', fakeAsync(() => { + testComponent.options = []; + testComponent.formControl = new FormControl(1); + fixture.detectChanges(); + + const selectComponent: NbSelectComponent = fixture.debugElement.query( + By.directive(NbSelectComponent), + ).componentInstance; + testComponent.options = [0]; + fixture.detectChanges(); + flush(); + fixture.detectChanges(); + + testComponent.optionComponents.first.onClick({ preventDefault() {} } as Event); + fixture.detectChanges(); + + const writeValueSpy = spyOn(selectComponent, 'writeValue').and.callThrough(); + testComponent.options.push(1); + fixture.detectChanges(); + flush(); + expect(writeValueSpy).not.toHaveBeenCalled(); + })); + + it(`should clear queue after option selected via 'selected' input`, fakeAsync(() => { + testComponent.options = []; + testComponent.formControl = new FormControl(1); + fixture.detectChanges(); + + const selectComponent: NbSelectComponent = fixture.debugElement.query( + By.directive(NbSelectComponent), + ).componentInstance; + testComponent.options = [0]; + fixture.detectChanges(); + flush(); + fixture.detectChanges(); + + selectComponent.selected = 0; + fixture.detectChanges(); + + const writeValueSpy = spyOn(selectComponent, 'writeValue').and.callThrough(); + testComponent.options.push(1); + fixture.detectChanges(); + flush(); + expect(writeValueSpy).not.toHaveBeenCalled(); + })); + + it('should clear queue after options change and selection model change', fakeAsync(() => { + testComponent.options = []; + testComponent.formControl = new FormControl(1); + fixture.detectChanges(); + + const selectComponent: NbSelectComponent = fixture.debugElement.query( + By.directive(NbSelectComponent), + ).componentInstance; + const writeValueSpy = spyOn(selectComponent, 'writeValue').and.callThrough(); + + testComponent.options = [1]; + fixture.detectChanges(); + flush(); + fixture.detectChanges(); + + expect(writeValueSpy).toHaveBeenCalledTimes(1); + + testComponent.options.push(2); + fixture.detectChanges(); + flush(); + fixture.detectChanges(); + + expect(writeValueSpy).toHaveBeenCalledTimes(1); + })); + + it('should not clear queue after options change and selection model is empty', fakeAsync(() => { + testComponent.options = []; + testComponent.formControl = new FormControl(2); + fixture.detectChanges(); + + const selectComponent: NbSelectComponent = fixture.debugElement.query( + By.directive(NbSelectComponent), + ).componentInstance; + const writeValueSpy = spyOn(selectComponent, 'writeValue').and.callThrough(); + + testComponent.options = [0]; + fixture.detectChanges(); + flush(); + fixture.detectChanges(); + expect(writeValueSpy).toHaveBeenCalledTimes(1); + + testComponent.options.push(1); + fixture.detectChanges(); + flush(); + fixture.detectChanges(); + expect(writeValueSpy).toHaveBeenCalledTimes(2); + })); + }); +}); + +@Component({ + template: ` + + + + {{ option }} + + + + `, +}) +export class NbSelectWithExperimentalSearchComponent { + options: number[] = [1, 2, 3, 4, 5]; + selectedValue: number = null; + filterValue: string = ''; + isOpened: boolean = false; + @ViewChild(NbSelectComponent) selectComponent: NbSelectComponent; +} + +describe('NbSelect - experimental search', () => { + let fixture: ComponentFixture; + let testComponent: NbSelectWithExperimentalSearchComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + RouterTestingModule.withRoutes([]), + FormsModule, + NbThemeModule.forRoot(), + NbLayoutModule, + NbSelectModule, + ], + declarations: [NbSelectWithExperimentalSearchComponent], + }); + + fixture = TestBed.createComponent(NbSelectWithExperimentalSearchComponent); + testComponent = fixture.componentInstance; + + fixture.detectChanges(); + }); + + it("should update search input and don't emit filterChange when value of select is changed", fakeAsync(() => { + const searchInput = testComponent.selectComponent.optionSearchInput.nativeElement; + + expect(searchInput.value).toEqual(''); + expect(testComponent.filterValue).toEqual(''); + + testComponent.selectedValue = 1; + fixture.detectChanges(); + flush(); + fixture.detectChanges(); + expect(searchInput.value).toEqual('1'); + expect(testComponent.filterValue).toEqual(''); + + testComponent.selectedValue = 2; + fixture.detectChanges(); + flush(); + fixture.detectChanges(); + expect(searchInput.value).toEqual('2'); + expect(testComponent.filterValue).toEqual(''); + })); + + it('should mark touched when select button loose focus and select closed', fakeAsync(() => { + const touchedSpy = jasmine.createSpy('touched spy'); + + const selectFixture = TestBed.createComponent(NbSelectComponent); + const selectComponent: NbSelectComponent = selectFixture.componentInstance; + selectFixture.detectChanges(); + flush(); + + selectComponent.registerOnTouched(touchedSpy); + selectFixture.debugElement.query(By.css('input')).triggerEventHandler('blur', {}); + expect(touchedSpy).toHaveBeenCalledTimes(1); + })); + + it('should make filter value empty and restore input to default after select is closed', fakeAsync(() => { + const searchInput = fixture.debugElement.query(By.css('input')); + + testComponent.selectedValue = 1; + + fixture.detectChanges(); + flush(); + fixture.detectChanges(); + + const initialValue = searchInput.nativeElement.value; + + testComponent.selectComponent.show(); + searchInput.triggerEventHandler('input', { target: { value: '123' } }); + + fixture.detectChanges(); + flush(); + fixture.detectChanges(); + + expect(testComponent.filterValue).toBe('123'); + + testComponent.selectComponent.hide(); + + fixture.detectChanges(); + flush(); + fixture.detectChanges(); + + expect(testComponent.filterValue).toBe(''); + expect(searchInput.nativeElement.value).toBe(initialValue); + })); +}); diff --git a/src/framework/theme/components/select/select.component.html b/src/framework/theme/components/select/select.component.html index 61a4e1ad2a..400faf85c9 100644 --- a/src/framework/theme/components/select/select.component.html +++ b/src/framework/theme/components/select/select.component.html @@ -1,5 +1,4 @@ - - - - - = new EventEmitter(); - @Output() selectOpen: EventEmitter = new EventEmitter(); - @Output() selectClose: EventEmitter = new EventEmitter(); - @Output() optionSearchChange: EventEmitter = new EventEmitter(); /** * List of `NbOptionComponent`'s components passed as content. @@ -741,8 +726,7 @@ export class NbSelectComponent * */ @ViewChild(NbPortalDirective) portal: NbPortalDirective; - @ViewChild('selectButton', { read: ElementRef }) button: ElementRef | undefined; - @ViewChild('optionSearchInput', { read: ElementRef }) optionSearchInput: ElementRef | undefined; + @ViewChild('selectButton', { read: ElementRef }) button: ElementRef; /** * Determines is select opened. @@ -752,10 +736,6 @@ export class NbSelectComponent return this.ref && this.ref.hasAttached(); } - get isOptionSearchInputAllowed(): boolean { - return this.withOptionSearch && this.isOpen && !this.multiple; - } - /** * List of selected options. * */ @@ -842,9 +822,6 @@ export class NbSelectComponent * Returns width of the select button. * */ get hostWidth(): number { - if (this.isOptionSearchInputAllowed) { - return this.optionSearchInput.nativeElement.getBoundingClientRect().width; - } return this.button.nativeElement.getBoundingClientRect().width; } @@ -872,7 +849,7 @@ export class NbSelectComponent return this.selectionModel.map((option: NbOptionComponent) => option.content).join(', '); } - return this.selectionModel[0]?.content ?? ''; + return this.selectionModel[0].content; } ngOnChanges({ disabled, status, size, fullWidth }: SimpleChanges) { @@ -934,24 +911,14 @@ export class NbSelectComponent } } - onInput(event: Event) { - this.optionSearchChange.emit((event.target as HTMLInputElement).value); - } - show() { if (this.shouldShow()) { this.attachToOverlay(); this.positionStrategy.positionChange.pipe(take(1), takeUntil(this.destroy$)).subscribe(() => { - if (this.isOptionSearchInputAllowed) { - this.optionSearchInput.nativeElement.focus(); - } else { - this.setActiveOption(); - } + this.setActiveOption(); }); - this.selectOpen.emit(); - this.cd.markForCheck(); } } @@ -960,10 +927,6 @@ export class NbSelectComponent if (this.isOpen) { this.ref.detach(); this.cd.markForCheck(); - this.selectClose.emit(); - - this.optionSearchInput.nativeElement.value = this.selectionView; - this.optionSearchChange.emit(''); } } @@ -1098,11 +1061,8 @@ export class NbSelectComponent } protected createPositionStrategy(): NbAdjustableConnectedPositionStrategy { - const element: ElementRef = this.withOptionSearch - ? this.optionSearchInput - : this.button; return this.positionBuilder - .connectedTo(element) + .connectedTo(this.button) .position(NbPosition.BOTTOM) .offset(this.optionsOverlayOffset) .adjustment(NbAdjustment.VERTICAL); @@ -1163,9 +1123,9 @@ export class NbSelectComponent ) .subscribe((event: KeyboardEvent) => { if (event.keyCode === ESCAPE) { - this.hide(); this.button.nativeElement.focus(); - } else if (!this.isOptionSearchInputAllowed) { + this.hide(); + } else { this.keyManager.onKeydown(event); } }); @@ -1177,21 +1137,11 @@ export class NbSelectComponent } protected subscribeOnButtonFocus() { - const buttonFocus$ = this.focusMonitor.monitor(this.button).pipe( - map((origin) => !!origin), - startWith(false), - finalize(() => this.focusMonitor.stopMonitoring(this.button)), - ); - - const filterInputFocus$ = this.focusMonitor.monitor(this.optionSearchInput).pipe( - map((origin) => !!origin), - startWith(false), - finalize(() => this.focusMonitor.stopMonitoring(this.button)), - ); - - combineLatest([buttonFocus$, filterInputFocus$]) + this.focusMonitor + .monitor(this.button) .pipe( - map(([buttonFocus, filterInputFocus]) => buttonFocus || filterInputFocus), + map((origin) => !!origin), + finalize(() => this.focusMonitor.stopMonitoring(this.button)), takeUntil(this.destroy$), ) .subscribe(this.focused$); diff --git a/src/framework/theme/components/select/select.module.ts b/src/framework/theme/components/select/select.module.ts index 47b39289ef..c0244f79ce 100644 --- a/src/framework/theme/components/select/select.module.ts +++ b/src/framework/theme/components/select/select.module.ts @@ -8,21 +8,11 @@ import { NbButtonModule } from '../button/button.module'; import { NbSelectComponent, NbSelectLabelComponent } from './select.component'; import { NbOptionModule } from '../option/option-list.module'; import { NbIconModule } from '../icon/icon.module'; -import { NbFormFieldModule } from '../form-field/form-field.module'; const NB_SELECT_COMPONENTS = [NbSelectComponent, NbSelectLabelComponent]; @NgModule({ - imports: [ - NbSharedModule, - NbOverlayModule, - NbButtonModule, - NbInputModule, - NbCardModule, - NbIconModule, - NbOptionModule, - NbFormFieldModule, - ], + imports: [NbSharedModule, NbOverlayModule, NbButtonModule, NbInputModule, NbCardModule, NbIconModule, NbOptionModule], exports: [...NB_SELECT_COMPONENTS, NbOptionModule], declarations: [...NB_SELECT_COMPONENTS], }) diff --git a/src/framework/theme/components/select/select.spec.ts b/src/framework/theme/components/select/select.spec.ts index 89cd092bd9..e30c3e5e4f 100644 --- a/src/framework/theme/components/select/select.spec.ts +++ b/src/framework/theme/components/select/select.spec.ts @@ -73,8 +73,6 @@ const TEST_GROUPS = [ [multiple]="multiple" [selected]="selected" (selectedChange)="selectedChange.emit($event)" - (selectOpen)="opened = true" - (selectClose)="opened = false" > {{ selected.split('').reverse().join('') }} @@ -95,7 +93,6 @@ export class NbSelectTestComponent { @Output() selectedChange: EventEmitter = new EventEmitter(); @ViewChildren(NbOptionComponent) options: QueryList>; groups = TEST_GROUPS; - opened = false; } @Component({ @@ -692,22 +689,6 @@ describe('Component: NbSelectComponent', () => { selectFixture.debugElement.query(By.css('.select-button')).triggerEventHandler('blur', {}); expect(touchedSpy).not.toHaveBeenCalled(); })); - - it('should emit open event after opening and close event after closing', fakeAsync(() => { - const selectFixture = TestBed.createComponent(NbSelectTestComponent); - select = selectFixture.debugElement.query(By.directive(NbSelectComponent)).componentInstance; - - selectFixture.detectChanges(); - expect(selectFixture.componentInstance.opened).toBe(false); - select.show(); - selectFixture.detectChanges(); - flush(); - expect(selectFixture.componentInstance.opened).toBe(true); - select.hide(); - selectFixture.detectChanges(); - flush(); - expect(selectFixture.componentInstance.opened).toBe(false); - })); }); describe('NbSelectComponent - falsy values', () => { @@ -1235,115 +1216,3 @@ describe('NbSelect - dynamic options', () => { })); }); }); - -@Component({ - template: ` - - - - {{ option }} - - - - `, -}) -export class NbSelectWithExperimentalSearchComponent { - options: number[] = [1, 2, 3, 4, 5]; - selectedValue: number = null; - filterValue: string = ''; - isOpened: boolean = false; - @ViewChild(NbSelectComponent) selectComponent: NbSelectComponent; -} - -describe('NbSelect - experimental search', () => { - let fixture: ComponentFixture; - let testComponent: NbSelectWithExperimentalSearchComponent; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ - RouterTestingModule.withRoutes([]), - FormsModule, - NbThemeModule.forRoot(), - NbLayoutModule, - NbSelectModule, - ], - declarations: [NbSelectWithExperimentalSearchComponent], - }); - - fixture = TestBed.createComponent(NbSelectWithExperimentalSearchComponent); - testComponent = fixture.componentInstance; - - fixture.detectChanges(); - }); - - it("should update search input and don't emit filterChange when value of select is changed", fakeAsync(() => { - const searchInput = testComponent.selectComponent.optionSearchInput.nativeElement; - - expect(searchInput.value).toEqual(''); - expect(testComponent.filterValue).toEqual(''); - - testComponent.selectedValue = 1; - fixture.detectChanges(); - flush(); - fixture.detectChanges(); - expect(searchInput.value).toEqual('1'); - expect(testComponent.filterValue).toEqual(''); - - testComponent.selectedValue = 2; - fixture.detectChanges(); - flush(); - fixture.detectChanges(); - expect(searchInput.value).toEqual('2'); - expect(testComponent.filterValue).toEqual(''); - })); - - it('should mark touched when select button loose focus and select closed', fakeAsync(() => { - const touchedSpy = jasmine.createSpy('touched spy'); - - const selectFixture = TestBed.createComponent(NbSelectComponent); - const selectComponent: NbSelectComponent = selectFixture.componentInstance; - selectFixture.detectChanges(); - flush(); - - selectComponent.registerOnTouched(touchedSpy); - selectFixture.debugElement.query(By.css('input')).triggerEventHandler('blur', {}); - expect(touchedSpy).toHaveBeenCalledTimes(1); - })); - - it('should make filter value empty and restore input to default after select is closed', fakeAsync(() => { - const searchInput = fixture.debugElement.query(By.css('input')); - - testComponent.selectedValue = 1; - - fixture.detectChanges(); - flush(); - fixture.detectChanges(); - - const initialValue = searchInput.nativeElement.value; - - testComponent.selectComponent.show(); - searchInput.triggerEventHandler('input', { target: { value: '123' } }); - - fixture.detectChanges(); - flush(); - fixture.detectChanges(); - - expect(testComponent.filterValue).toBe('123'); - - testComponent.selectComponent.hide(); - - fixture.detectChanges(); - flush(); - fixture.detectChanges(); - - expect(testComponent.filterValue).toBe(''); - expect(searchInput.nativeElement.value).toBe(initialValue); - })); -}); diff --git a/src/framework/theme/public_api.ts b/src/framework/theme/public_api.ts index e7d02dc0e4..bcc56f9a55 100644 --- a/src/framework/theme/public_api.ts +++ b/src/framework/theme/public_api.ts @@ -181,7 +181,9 @@ export * from './components/tooltip/tooltip.module'; export * from './components/tooltip/tooltip.directive'; export * from './components/tooltip/tooltip.component'; export * from './components/select/select.module'; +export * from './components/select-with-autocomplete/select-with-autocomplete.module'; export * from './components/select/select.component'; +export * from './components/select-with-autocomplete/select-with-autocomplete.component'; export * from './components/option/option-list.module'; export * from './components/option/option.component'; export * from './components/option/option-group.component'; diff --git a/src/framework/theme/styles/global/_components.scss b/src/framework/theme/styles/global/_components.scss index 8937e1eb52..407200bc9b 100644 --- a/src/framework/theme/styles/global/_components.scss +++ b/src/framework/theme/styles/global/_components.scss @@ -36,6 +36,7 @@ @forward '../../components/popover/popover.component.theme'; @forward '../../components/context-menu/context-menu.component.theme'; @forward '../../components/select/select.component.theme'; +@forward '../../components/select-with-autocomplete/select-with-autocomplete.component.theme'; @forward '../../components/option/option-list.component.theme'; @forward '../../components/toastr/toast.component.theme'; @forward '../../components/tooltip/tooltip.component.theme'; @@ -77,6 +78,8 @@ @use '../../components/popover/popover.component.theme' as popover-theme; @use '../../components/context-menu/context-menu.component.theme' as context-menu-theme; @use '../../components/select/select.component.theme' as select-theme; +@use '../../components/select-with-autocomplete/select-with-autocomplete.component.theme' as + select-with-autocomplete-theme; @use '../../components/option/option-list.component.theme' as option-list-theme; @use '../../components/toastr/toast.component.theme' as toast-theme; @use '../../components/tooltip/tooltip.component.theme' as tooltip-theme; @@ -120,6 +123,7 @@ @include popover-theme.nb-popover-theme(); @include context-menu-theme.nb-context-menu-theme(); @include select-theme.nb-select-theme(); + @include select-with-autocomplete-theme.nb-select-with-autocomplete-theme(); @include option-list-theme.nb-option-list-theme(); @include toast-theme.nb-toast-theme(); @include tooltip-theme.nb-tooltip-theme(); diff --git a/src/playground/with-layout/select/select-search-showcase.component.html b/src/playground/with-layout/select/select-autocomplete-showcase.component.html similarity index 50% rename from src/playground/with-layout/select/select-search-showcase.component.html rename to src/playground/with-layout/select/select-autocomplete-showcase.component.html index c023421de4..1ff4f77818 100644 --- a/src/playground/with-layout/select/select-search-showcase.component.html +++ b/src/playground/with-layout/select/select-autocomplete-showcase.component.html @@ -1,10 +1,11 @@ - Toggle autocomplete: {{ withAutocomplete }} + Option empty Option 0 @@ -12,14 +13,27 @@ Option 2 Option 3 Option 4 - - + + + Option empty + Option 0 + Option 1 + Option 2 + Option 3 + Option 4 + + Option empty Option 0 Option 1 Option 2 Option 3 Option 4 - + diff --git a/src/playground/with-layout/select/select-search-showcase.component.ts b/src/playground/with-layout/select/select-autocomplete-showcase.component.ts similarity index 78% rename from src/playground/with-layout/select/select-search-showcase.component.ts rename to src/playground/with-layout/select/select-autocomplete-showcase.component.ts index 429f8686b5..e4532752a9 100644 --- a/src/playground/with-layout/select/select-search-showcase.component.ts +++ b/src/playground/with-layout/select/select-autocomplete-showcase.component.ts @@ -7,10 +7,11 @@ import { Component } from '@angular/core'; @Component({ - selector: 'npg-select-search-showcase', - templateUrl: './select-search-showcase.component.html', + selector: 'npg-select-autocomplete-showcase', + templateUrl: './select-autocomplete-showcase.component.html', }) -export class SelectSearchShowcaseComponent { +export class SelectAutocompleteShowcaseComponent { + withAutocomplete = true; selectedItem = '2'; filterValue = ''; diff --git a/src/playground/with-layout/select/select-routing.module.ts b/src/playground/with-layout/select/select-routing.module.ts index da1b2b25ed..0641f11d9b 100644 --- a/src/playground/with-layout/select/select-routing.module.ts +++ b/src/playground/with-layout/select/select-routing.module.ts @@ -23,7 +23,7 @@ import { SelectInteractiveComponent } from './select-interactive.component'; import { SelectTestComponent } from './select-test.component'; import { SelectCompareWithComponent } from './select-compare-with.component'; import { SelectIconComponent } from './select-icon.component'; -import { SelectSearchShowcaseComponent } from './select-search-showcase.component'; +import { SelectAutocompleteShowcaseComponent } from './select-autocomplete-showcase.component'; const routes: Route[] = [ { @@ -95,8 +95,8 @@ const routes: Route[] = [ component: SelectIconComponent, }, { - path: 'select-search-showcase.component', - component: SelectSearchShowcaseComponent, + path: 'select-autocomplete-showcase.component', + component: SelectAutocompleteShowcaseComponent, }, ]; diff --git a/src/playground/with-layout/select/select.module.ts b/src/playground/with-layout/select/select.module.ts index 1f1ba089df..d1e84b832c 100644 --- a/src/playground/with-layout/select/select.module.ts +++ b/src/playground/with-layout/select/select.module.ts @@ -12,9 +12,9 @@ import { NbCardModule, NbFormFieldModule, NbIconModule, - NbInputModule, NbRadioModule, NbSelectModule, + NbSelectWithAutocompleteModule, } from '@nebular/theme'; import { SelectRoutingModule } from './select-routing.module'; import { SelectCleanComponent } from './select-clean.component'; @@ -34,7 +34,7 @@ import { SelectInteractiveComponent } from './select-interactive.component'; import { SelectTestComponent } from './select-test.component'; import { SelectCompareWithComponent } from './select-compare-with.component'; import { SelectIconComponent } from './select-icon.component'; -import { SelectSearchShowcaseComponent } from './select-search-showcase.component'; +import { SelectAutocompleteShowcaseComponent } from './select-autocomplete-showcase.component'; @NgModule({ declarations: [ @@ -55,12 +55,13 @@ import { SelectSearchShowcaseComponent } from './select-search-showcase.componen SelectTestComponent, SelectCompareWithComponent, SelectIconComponent, - SelectSearchShowcaseComponent, + SelectAutocompleteShowcaseComponent, ], imports: [ FormsModule, ReactiveFormsModule, NbSelectModule, + NbSelectWithAutocompleteModule, SelectRoutingModule, NbCardModule, CommonModule, @@ -68,7 +69,6 @@ import { SelectSearchShowcaseComponent } from './select-search-showcase.componen NbButtonModule, NbIconModule, NbFormFieldModule, - NbInputModule, ], }) export class SelectModule {}