diff --git a/src/app/playground-components.ts b/src/app/playground-components.ts index 62c75f7165..f8782fe708 100644 --- a/src/app/playground-components.ts +++ b/src/app/playground-components.ts @@ -1161,6 +1161,12 @@ export const PLAYGROUND_COMPONENTS: ComponentLink[] = [ component: 'SelectIconComponent', name: 'Select Icon', }, + { + path: 'select-search-showcase.component', + link: '/select/select-search-showcase.component', + component: 'SelectSearchShowcaseComponent', + name: 'Select Search Showcase', + }, ], }, { diff --git a/src/framework/theme/components/form-field/form-field.component.scss b/src/framework/theme/components/form-field/form-field.component.scss index 563aae7950..a33796a6b5 100644 --- a/src/framework/theme/components/form-field/form-field.component.scss +++ b/src/framework/theme/components/form-field/form-field.component.scss @@ -7,6 +7,9 @@ :host { display: flex; align-items: center; + &[hidden] { + display: none; + } } .nb-form-control-container { diff --git a/src/framework/theme/components/option/option.component.scss b/src/framework/theme/components/option/option.component.scss index 81da9f3399..f830b3b1f3 100644 --- a/src/framework/theme/components/option/option.component.scss +++ b/src/framework/theme/components/option/option.component.scss @@ -8,6 +8,9 @@ :host { display: flex; + &[hidden] { + display: none; + } &:hover { cursor: pointer; diff --git a/src/framework/theme/components/select/select.component.html b/src/framework/theme/components/select/select.component.html index 400faf85c9..61a4e1ad2a 100644 --- a/src/framework/theme/components/select/select.component.html +++ b/src/framework/theme/components/select/select.component.html @@ -1,4 +1,5 @@ + + + + + = 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. @@ -726,7 +741,8 @@ export class NbSelectComponent * */ @ViewChild(NbPortalDirective) portal: NbPortalDirective; - @ViewChild('selectButton', { read: ElementRef }) button: ElementRef; + @ViewChild('selectButton', { read: ElementRef }) button: ElementRef | undefined; + @ViewChild('optionSearchInput', { read: ElementRef }) optionSearchInput: ElementRef | undefined; /** * Determines is select opened. @@ -736,6 +752,10 @@ export class NbSelectComponent return this.ref && this.ref.hasAttached(); } + get isOptionSearchInputAllowed(): boolean { + return this.withOptionSearch && this.isOpen && !this.multiple; + } + /** * List of selected options. * */ @@ -822,6 +842,9 @@ 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; } @@ -849,7 +872,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) { @@ -911,14 +934,24 @@ 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(() => { - this.setActiveOption(); + if (this.isOptionSearchInputAllowed) { + this.optionSearchInput.nativeElement.focus(); + } else { + this.setActiveOption(); + } }); + this.selectOpen.emit(); + this.cd.markForCheck(); } } @@ -927,6 +960,10 @@ export class NbSelectComponent if (this.isOpen) { this.ref.detach(); this.cd.markForCheck(); + this.selectClose.emit(); + + this.optionSearchInput.nativeElement.value = this.selectionView; + this.optionSearchChange.emit(''); } } @@ -1061,8 +1098,11 @@ export class NbSelectComponent } protected createPositionStrategy(): NbAdjustableConnectedPositionStrategy { + const element: ElementRef = this.withOptionSearch + ? this.optionSearchInput + : this.button; return this.positionBuilder - .connectedTo(this.button) + .connectedTo(element) .position(NbPosition.BOTTOM) .offset(this.optionsOverlayOffset) .adjustment(NbAdjustment.VERTICAL); @@ -1123,9 +1163,9 @@ export class NbSelectComponent ) .subscribe((event: KeyboardEvent) => { if (event.keyCode === ESCAPE) { - this.button.nativeElement.focus(); this.hide(); - } else { + this.button.nativeElement.focus(); + } else if (!this.isOptionSearchInputAllowed) { this.keyManager.onKeydown(event); } }); @@ -1137,11 +1177,21 @@ export class NbSelectComponent } protected subscribeOnButtonFocus() { - this.focusMonitor - .monitor(this.button) + 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$]) .pipe( - map((origin) => !!origin), - finalize(() => this.focusMonitor.stopMonitoring(this.button)), + map(([buttonFocus, filterInputFocus]) => buttonFocus || filterInputFocus), 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 4a72fe8601..47b39289ef 100644 --- a/src/framework/theme/components/select/select.module.ts +++ b/src/framework/theme/components/select/select.module.ts @@ -8,11 +8,9 @@ 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, -]; +const NB_SELECT_COMPONENTS = [NbSelectComponent, NbSelectLabelComponent]; @NgModule({ imports: [ @@ -23,12 +21,9 @@ const NB_SELECT_COMPONENTS = [ NbCardModule, NbIconModule, NbOptionModule, + NbFormFieldModule, ], - exports: [ - ...NB_SELECT_COMPONENTS, - NbOptionModule, - ], + exports: [...NB_SELECT_COMPONENTS, NbOptionModule], declarations: [...NB_SELECT_COMPONENTS], }) -export class NbSelectModule { -} +export class NbSelectModule {} diff --git a/src/framework/theme/components/select/select.spec.ts b/src/framework/theme/components/select/select.spec.ts index e30c3e5e4f..89cd092bd9 100644 --- a/src/framework/theme/components/select/select.spec.ts +++ b/src/framework/theme/components/select/select.spec.ts @@ -73,6 +73,8 @@ const TEST_GROUPS = [ [multiple]="multiple" [selected]="selected" (selectedChange)="selectedChange.emit($event)" + (selectOpen)="opened = true" + (selectClose)="opened = false" > {{ selected.split('').reverse().join('') }} @@ -93,6 +95,7 @@ export class NbSelectTestComponent { @Output() selectedChange: EventEmitter = new EventEmitter(); @ViewChildren(NbOptionComponent) options: QueryList>; groups = TEST_GROUPS; + opened = false; } @Component({ @@ -689,6 +692,22 @@ 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', () => { @@ -1216,3 +1235,115 @@ 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/playground/with-layout/select/select-routing.module.ts b/src/playground/with-layout/select/select-routing.module.ts index 3e43a17148..da1b2b25ed 100644 --- a/src/playground/with-layout/select/select-routing.module.ts +++ b/src/playground/with-layout/select/select-routing.module.ts @@ -5,7 +5,7 @@ */ import { NgModule } from '@angular/core'; -import { RouterModule, Route} from '@angular/router'; +import { RouterModule, Route } from '@angular/router'; import { SelectCleanComponent } from './select-clean.component'; import { SelectDisabledComponent } from './select-disabled.component'; import { SelectFormComponent } from './select-form.component'; @@ -23,6 +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'; const routes: Route[] = [ { @@ -93,10 +94,14 @@ const routes: Route[] = [ path: 'select-icon.component', component: SelectIconComponent, }, + { + path: 'select-search-showcase.component', + component: SelectSearchShowcaseComponent, + }, ]; @NgModule({ - imports: [ RouterModule.forChild(routes) ], - exports: [ RouterModule ], + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], }) export class SelectRoutingModule {} diff --git a/src/playground/with-layout/select/select-search-showcase.component.html b/src/playground/with-layout/select/select-search-showcase.component.html new file mode 100644 index 0000000000..c023421de4 --- /dev/null +++ b/src/playground/with-layout/select/select-search-showcase.component.html @@ -0,0 +1,25 @@ + + + + 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-search-showcase.component.ts new file mode 100644 index 0000000000..429f8686b5 --- /dev/null +++ b/src/playground/with-layout/select/select-search-showcase.component.ts @@ -0,0 +1,42 @@ +/* + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { Component } from '@angular/core'; + +@Component({ + selector: 'npg-select-search-showcase', + templateUrl: './select-search-showcase.component.html', +}) +export class SelectSearchShowcaseComponent { + selectedItem = '2'; + filterValue = ''; + + isEqual(a?, b?, c?): boolean { + if (!a) { + return true; + } + + a = a?.toString().toLowerCase() ?? ''; + a = new RegExp( + a + .split('') + .map((letter) => letter + '.*') + .join(''), + ); + b = b?.toString().toLowerCase() ?? ''; + c = c?.toString().toLowerCase() ?? ''; + + if (b && a.test(b)) { + return true; + } + + if (c && a.test(c)) { + return true; + } + + return false; + } +} diff --git a/src/playground/with-layout/select/select.module.ts b/src/playground/with-layout/select/select.module.ts index 72eae09de5..1f1ba089df 100644 --- a/src/playground/with-layout/select/select.module.ts +++ b/src/playground/with-layout/select/select.module.ts @@ -12,6 +12,7 @@ import { NbCardModule, NbFormFieldModule, NbIconModule, + NbInputModule, NbRadioModule, NbSelectModule, } from '@nebular/theme'; @@ -33,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'; @NgModule({ declarations: [ @@ -54,6 +55,7 @@ import { SelectIconComponent } from './select-icon.component'; SelectTestComponent, SelectCompareWithComponent, SelectIconComponent, + SelectSearchShowcaseComponent, ], imports: [ FormsModule, @@ -66,6 +68,7 @@ import { SelectIconComponent } from './select-icon.component'; NbButtonModule, NbIconModule, NbFormFieldModule, + NbInputModule, ], }) -export class SelectModule { } +export class SelectModule {}