diff --git a/jest.setup.ts b/jest.setup.ts index aa5a853b..e06f1834 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -1,2 +1,2 @@ // https://github.com/testing-library/jest-dom -import '@testing-library/jest-dom/extend-expect'; +import '@testing-library/jest-dom'; diff --git a/projects/elonkit/src/public-api.ts b/projects/elonkit/src/public-api.ts index 1fb27f35..4db48736 100644 --- a/projects/elonkit/src/public-api.ts +++ b/projects/elonkit/src/public-api.ts @@ -1,3 +1,4 @@ +export * from './ui/autocomplete'; export * from './ui/counter'; export * from './ui/inline-form-field'; export * from './ui/timepicker'; diff --git a/projects/elonkit/src/ui/autocomplete/__specs__/autocomplete.spec.ts b/projects/elonkit/src/ui/autocomplete/__specs__/autocomplete.spec.ts new file mode 100644 index 00000000..106c697a --- /dev/null +++ b/projects/elonkit/src/ui/autocomplete/__specs__/autocomplete.spec.ts @@ -0,0 +1,236 @@ +import { Component } from '@angular/core'; +import { inject, fakeAsync, tick } from '@angular/core/testing'; + +import { FormsModule } from '@angular/forms'; + +import { OverlayContainer } from '@angular/cdk/overlay'; +import { MatFormFieldModule } from '@angular/material/form-field'; + +import { render, RenderResult } from '@testing-library/angular'; + +import { AutocompleteModule, AutocompleteComponent } from '..'; + +@Component({ + template: ` + + Friend + + + + {{ option.name }} + + + + ` +}) +class AutocompleteCustomComponent { + public text = ''; + public options: any[] = FRIENDS; + public valueFn(option: any): any { + return option.name; + } +} + +const FRUITS = ['Apple', 'Lemon', 'Mango']; +const FRIENDS = [ + { + name: 'Anna', + photo: 'https://joeschmoe.io/api/v1/jenni' + }, + { + name: 'Mary', + photo: 'https://joeschmoe.io/api/v1/julie' + } +]; + +describe('Autocomplete', () => { + describe('Base', () => { + let component: RenderResult; + let overlay: OverlayContainer; + let overlayElement: HTMLElement; + + beforeEach(async () => { + component = await render(AutocompleteComponent, { + imports: [AutocompleteModule], + componentProperties: { + options: FRUITS + }, + excludeComponentDeclaration: true + }); + + inject([OverlayContainer], (oc: OverlayContainer) => { + overlay = oc; + overlayElement = oc.getContainerElement(); + })(); + }); + + afterEach(inject([OverlayContainer], (currentOverlay: OverlayContainer) => { + currentOverlay.ngOnDestroy(); + overlay.ngOnDestroy(); + })); + + it('Should display passed options', fakeAsync(async () => { + const input = component.getByTestId('input'); + + component.focusIn(input); + + const options = overlayElement.querySelectorAll('[data-testid="mat-option"]'); + expect(options).toHaveLength(3); + + // all items of FRUITS array contain in overlay + for (const fruit of FRUITS) { + expect(overlayElement.textContent).toContain(fruit); + } + })); + + it('Should change value on input text in view of debounce time', fakeAsync(async () => { + const TEXT_FOO = 'Foo'; + const TEXT_BAR = 'Bar'; + const onChangeText = jest.fn(); + component.fixture.componentInstance.changeText.emit = onChangeText; + + const input = component.getByTestId('input'); + + component.input(input, { target: { value: TEXT_FOO } }); + + tick(); + expect(onChangeText).toBeCalledTimes(1); + expect(onChangeText).toBeCalledWith(TEXT_FOO); + + component.fixture.componentInstance.debounceTime = 100; + component.fixture.componentInstance.changeDetector.detectChanges(); + + component.input(input, { target: { value: TEXT_BAR } }); + + tick(99); + expect(onChangeText).toBeCalledTimes(1); + + tick(1); + expect(onChangeText).toBeCalledTimes(2); + expect(onChangeText).toBeCalledWith(TEXT_BAR); + })); + + it('Should display entered text any kind', fakeAsync(async () => { + const TEXT_BAZ = 'Baz'; + const TEXT_APPLE_SMTH = 'Apple232323'; + + component.fixture.componentInstance.freeInput = true; + component.fixture.componentInstance.changeDetector.detectChanges(); + + const input = component.getByTestId('input'); + + // if user did not choose some option + component.focusIn(input); + tick(); + + component.input(input, { target: { value: TEXT_BAZ } }); + tick(); + + component.blur(input); + tick(); + + expect(component.getByDisplayValue(TEXT_BAZ)); + + // if user choose some option + component.focusIn(input); + tick(); + + const options = overlayElement.querySelectorAll('[data-testid="mat-option"]'); + component.click(options[0]); + tick(); + + component.input(input, { target: { value: TEXT_APPLE_SMTH } }); + tick(); + + component.blur(input); + tick(); + + expect(component.getByDisplayValue(TEXT_APPLE_SMTH)); + })); + + it('Should display entered text only from options', fakeAsync(async () => { + const TEXT_BAZ = 'Baz'; + const TEXT_APPLE = 'Apple'; + const TEXT_APPLE_SMTH = 'Apple232323'; + + component.fixture.componentInstance.freeInput = false; + component.fixture.componentInstance.changeDetector.detectChanges(); + + const input = component.getByTestId('input'); + + // if user did not choose some option + component.focusIn(input); + tick(); + + component.input(input, { target: { value: TEXT_BAZ } }); + tick(); + + component.blur(input); + tick(); + + expect(component.getByDisplayValue('')); + + // if user choose some option + component.focusIn(input); + tick(); + + const options = overlayElement.querySelectorAll('[data-testid="mat-option"]'); + component.click(options[0]); + tick(); + + component.input(input, { target: { value: TEXT_APPLE_SMTH } }); + tick(); + + component.blur(input); + tick(); + + expect(component.getByDisplayValue(TEXT_APPLE)); + })); + }); + + describe('Custom options', () => { + let component: RenderResult; + let overlay: OverlayContainer; + let overlayElement: HTMLElement; + + beforeEach(async () => { + component = await render(AutocompleteCustomComponent, { + imports: [AutocompleteModule, FormsModule, MatFormFieldModule], + componentProperties: { + options: FRIENDS + } + }); + + inject([OverlayContainer], (oc: OverlayContainer) => { + overlay = oc; + overlayElement = oc.getContainerElement(); + })(); + }); + + afterEach(inject([OverlayContainer], (currentOverlay: OverlayContainer) => { + currentOverlay.ngOnDestroy(); + overlay.ngOnDestroy(); + })); + + it('Should display options with photo and name', fakeAsync(async () => { + const input = component.getByTestId('input'); + + component.focusIn(input); + + const options = overlayElement.querySelectorAll('[data-testid="mat-option"]'); + + expect(options).toHaveLength(2); + + for (let i = 0; i < FRIENDS.length; i++) { + const image = options[i].querySelector('img'); + expect(options[i].textContent).toContain(FRIENDS[i].name); + expect(image).toHaveAttribute('src', FRIENDS[i].photo); + } + })); + }); +}); diff --git a/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-custom/autocomplete-story-custom.component.html b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-custom/autocomplete-story-custom.component.html new file mode 100644 index 00000000..28629f72 --- /dev/null +++ b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-custom/autocomplete-story-custom.component.html @@ -0,0 +1,17 @@ +
+ + Friend + + + + {{ option.name }} + {{ option.id }} + + + +
diff --git a/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-custom/autocomplete-story-custom.component.scss b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-custom/autocomplete-story-custom.component.scss new file mode 100644 index 00000000..6e410178 --- /dev/null +++ b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-custom/autocomplete-story-custom.component.scss @@ -0,0 +1,11 @@ +.es-autocomplete-story-custom { + &__option-img { + height: 25px; + margin-right: 8px; + vertical-align: middle; + } + + &.mat-form-field { + width: 100%; + } +} diff --git a/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-custom/autocomplete-story-custom.component.ts b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-custom/autocomplete-story-custom.component.ts new file mode 100644 index 00000000..c4751fd2 --- /dev/null +++ b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-custom/autocomplete-story-custom.component.ts @@ -0,0 +1,47 @@ +import { Component, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { FormGroup, FormBuilder } from '@angular/forms'; +import { GetFilterOptionsByKey } from '../../filter-options'; + +const OPTIONS = [ + { + id: 1, + name: 'Anna', + photo: 'https://joeschmoe.io/api/v1/jenni' + }, + { + id: 2, + name: 'Mary', + photo: 'https://joeschmoe.io/api/v1/julie' + }, + { + id: 3, + name: 'Elena', + photo: 'https://joeschmoe.io/api/v1/jolee' + } +]; + +@Component({ + selector: 'es-autocomplete-story-custom', + templateUrl: './autocomplete-story-custom.component.html', + styleUrls: ['./autocomplete-story-custom.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None +}) +export class AutocompleteStoryCustomComponent { + public form: FormGroup; + public options: any[] = OPTIONS; + + constructor(private formBuilder: FormBuilder) { + this.form = this.formBuilder.group({ + autocomplete: '' + }); + } + + public onChangeText(text: string) { + this.options = GetFilterOptionsByKey(text, OPTIONS, 'name'); + } + + public valueFn(option: any): any { + return option.name; + } +} diff --git a/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-custom/autocomplete-story-custom.module.ts b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-custom/autocomplete-story-custom.module.ts new file mode 100644 index 00000000..1c1047f1 --- /dev/null +++ b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-custom/autocomplete-story-custom.module.ts @@ -0,0 +1,23 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ReactiveFormsModule } from '@angular/forms'; + +import { AutocompleteModule } from '../../autocomplete.module'; + +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatButtonModule } from '@angular/material/button'; + +import { AutocompleteStoryCustomComponent } from './autocomplete-story-custom.component'; + +@NgModule({ + declarations: [AutocompleteStoryCustomComponent], + imports: [ + CommonModule, + ReactiveFormsModule, + AutocompleteModule, + MatFormFieldModule, + MatButtonModule + ], + exports: [AutocompleteStoryCustomComponent] +}) +export class AutocompleteStoryCustomModule {} diff --git a/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-custom/autocomplete-story-custom.source.ts b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-custom/autocomplete-story-custom.source.ts new file mode 100644 index 00000000..54212670 --- /dev/null +++ b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-custom/autocomplete-story-custom.source.ts @@ -0,0 +1,70 @@ +export const AUTOCOMPLETE_STORY_CUSTOM_SOURCE = { + html: ` +
+ + Friend + + + + {{ option.name }} + {{ option.id }} + + + +
`, + ts: ` + import { GetFilterOptionsByKey } from '@elonsoft/elonkit/autocomplete'; + + const OPTIONS = [ + { + id: 1, + name: 'Anna', + photo: 'https://joeschmoe.io/api/v1/jenni' + }, + { + id: 2, + name: 'Mary', + photo: 'https://joeschmoe.io/api/v1/julie' + }, + { + id: 3, + name: 'Elena', + photo: 'https://joeschmoe.io/api/v1/jolee' + } + ]; + + @Component(...) + export class AutocompleteStoryCustomComponent { + public form: FormGroup; + public options = OPTIONS; + + constructor(private formBuilder: FormBuilder) { + this.form = this.formBuilder.group({ + autocomplete: '' + }); + } + + public onChangeText(text: string) { + this.options = GetFilterOptionsByKey(text, OPTIONS, 'name'); + } + + public valueFn(option: any): any { + return option.name; + } + } + `, + scss: ` + .es-autocomplete-story-custom { + &__image { + height: 25px; + margin-right: 8px; + vertical-align: middle; + } + } + ` +}; diff --git a/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-custom/index.ts b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-custom/index.ts new file mode 100644 index 00000000..0e04b910 --- /dev/null +++ b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-custom/index.ts @@ -0,0 +1 @@ +export { AUTOCOMPLETE_STORY_CUSTOM_SOURCE } from './autocomplete-story-custom.source'; diff --git a/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-default/autocomplete-story-default.component.html b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-default/autocomplete-story-default.component.html new file mode 100644 index 00000000..50064a1c --- /dev/null +++ b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-default/autocomplete-story-default.component.html @@ -0,0 +1,13 @@ +
+ + Fruit + + + +
diff --git a/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-default/autocomplete-story-default.component.scss b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-default/autocomplete-story-default.component.scss new file mode 100644 index 00000000..3aa2dc52 --- /dev/null +++ b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-default/autocomplete-story-default.component.scss @@ -0,0 +1,5 @@ +.es-autocomplete-story-default { + &.mat-form-field { + width: 100%; + } +} diff --git a/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-default/autocomplete-story-default.component.ts b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-default/autocomplete-story-default.component.ts new file mode 100644 index 00000000..1c349311 --- /dev/null +++ b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-default/autocomplete-story-default.component.ts @@ -0,0 +1,29 @@ +import { Component, ChangeDetectionStrategy, ViewEncapsulation, Input } from '@angular/core'; +import { FormGroup, FormBuilder } from '@angular/forms'; +import { GetFilterOptions } from '../../filter-options'; + +const OPTIONS = ['Apple', 'Lemon', 'Mango']; + +@Component({ + selector: 'es-autocomplete-story-default', + templateUrl: './autocomplete-story-default.component.html', + styleUrls: ['./autocomplete-story-default.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None +}) +export class AutocompleteStoryDefaultComponent { + public form: FormGroup; + public options: any[] = OPTIONS; + @Input() public debounceTime: number; + @Input() public freeInput: boolean; + + constructor(private formBuilder: FormBuilder) { + this.form = this.formBuilder.group({ + autocomplete: '' + }); + } + + public onChangeText(text: string) { + this.options = GetFilterOptions(text, OPTIONS); + } +} diff --git a/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-default/autocomplete-story-default.module.ts b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-default/autocomplete-story-default.module.ts new file mode 100644 index 00000000..7307afb1 --- /dev/null +++ b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-default/autocomplete-story-default.module.ts @@ -0,0 +1,23 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ReactiveFormsModule } from '@angular/forms'; + +import { AutocompleteModule } from '../../autocomplete.module'; + +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatButtonModule } from '@angular/material/button'; + +import { AutocompleteStoryDefaultComponent } from './autocomplete-story-default.component'; + +@NgModule({ + declarations: [AutocompleteStoryDefaultComponent], + imports: [ + CommonModule, + ReactiveFormsModule, + AutocompleteModule, + MatFormFieldModule, + MatButtonModule + ], + exports: [AutocompleteStoryDefaultComponent] +}) +export class AutocompleteStoryDefaultModule {} diff --git a/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-default/autocomplete-story-default.source.ts b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-default/autocomplete-story-default.source.ts new file mode 100644 index 00000000..9c3f755e --- /dev/null +++ b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-default/autocomplete-story-default.source.ts @@ -0,0 +1,37 @@ +export const AUTOCOMPLETE_STORY_DEFAULT_SOURCE = { + html: ` +
+ + Fruit + + + +
`, + ts: ` + import { GetFilterOptions } from '@elonsoft/elonkit/autocomplete'; + + const OPTIONS = ['Apple', 'Lemon', 'Mango']; + + @Component(...) + export class AutocompleteStoryDefaultComponent { + public form: FormGroup; + public options = OPTIONS; + + constructor(private formBuilder: FormBuilder) { + this.form = this.formBuilder.group({ + autocomplete: '' + }); + } + + public onChangeText(text: string) { + this.options = GetFilterOptions(text, OPTIONS); + } + } + ` +}; diff --git a/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-default/index.ts b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-default/index.ts new file mode 100644 index 00000000..5587652b --- /dev/null +++ b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-default/index.ts @@ -0,0 +1 @@ +export { AUTOCOMPLETE_STORY_DEFAULT_SOURCE } from './autocomplete-story-default.source'; diff --git a/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-service/autocomplete-story-service.component.html b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-service/autocomplete-story-service.component.html new file mode 100644 index 00000000..b42e3b58 --- /dev/null +++ b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-service/autocomplete-story-service.component.html @@ -0,0 +1,13 @@ +
+ + Color + + + +
diff --git a/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-service/autocomplete-story-service.component.scss b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-service/autocomplete-story-service.component.scss new file mode 100644 index 00000000..8dec1387 --- /dev/null +++ b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-service/autocomplete-story-service.component.scss @@ -0,0 +1,5 @@ +.es-autocomplete-story-service { + &.mat-form-field { + width: 100%; + } +} diff --git a/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-service/autocomplete-story-service.component.ts b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-service/autocomplete-story-service.component.ts new file mode 100644 index 00000000..f690517b --- /dev/null +++ b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-service/autocomplete-story-service.component.ts @@ -0,0 +1,62 @@ +import { + Component, + ChangeDetectionStrategy, + ViewEncapsulation, + ChangeDetectorRef, + OnDestroy +} from '@angular/core'; +import { FormGroup, FormBuilder } from '@angular/forms'; +import { AutocompleteStoryServiceService } from './autocomplete-story-service.service'; + +import { BehaviorSubject, Subscription } from 'rxjs'; +import { switchMap, tap } from 'rxjs/operators'; + +@Component({ + selector: 'es-autocomplete-story-service', + templateUrl: './autocomplete-story-service.component.html', + styleUrls: ['./autocomplete-story-service.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None +}) +export class AutocompleteStoryServiceComponent implements OnDestroy { + private text$ = new BehaviorSubject(''); + + public form: FormGroup; + public options: string[]; + public isLoading = false; + private subscription: Subscription | null = null; + + constructor( + private formBuilder: FormBuilder, + private autocompleteService: AutocompleteStoryServiceService, + private changeDetector: ChangeDetectorRef + ) { + this.form = this.formBuilder.group({ + autocomplete: '' + }); + + this.subscription = this.text$ + .pipe( + tap(() => { + this.isLoading = true; + }), + switchMap(text => this.autocompleteService.getOptions(text)) + ) + .subscribe(options => { + this.options = options.options; + this.isLoading = false; + this.changeDetector.detectChanges(); + }); + } + + public ngOnDestroy() { + if (this.subscription) { + this.subscription.unsubscribe(); + this.subscription = null; + } + } + + public onChangeText(text: string) { + this.text$.next(text); + } +} diff --git a/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-service/autocomplete-story-service.module.ts b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-service/autocomplete-story-service.module.ts new file mode 100644 index 00000000..963d354b --- /dev/null +++ b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-service/autocomplete-story-service.module.ts @@ -0,0 +1,27 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ReactiveFormsModule } from '@angular/forms'; + +import { AutocompleteModule } from '../..'; + +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; + +import { AutocompleteStoryServiceComponent } from './autocomplete-story-service.component'; +import { AutocompleteStoryServiceService } from './autocomplete-story-service.service'; + +@NgModule({ + declarations: [AutocompleteStoryServiceComponent], + imports: [ + CommonModule, + ReactiveFormsModule, + + AutocompleteModule, + + MatFormFieldModule, + MatButtonModule + ], + exports: [AutocompleteStoryServiceComponent], + providers: [AutocompleteStoryServiceService] +}) +export class AutocompleteStoryServiceModule {} diff --git a/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-service/autocomplete-story-service.service.ts b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-service/autocomplete-story-service.service.ts new file mode 100644 index 00000000..0ef6c49f --- /dev/null +++ b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-service/autocomplete-story-service.service.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@angular/core'; + +import { delay } from 'rxjs/operators'; +import { of, Observable } from 'rxjs'; + +import { GetFilterOptions } from '../../filter-options'; + +const OPTIONS = ['Red', 'White', 'Green']; + +@Injectable() +export class AutocompleteStoryServiceService { + public getOptions(text?: string): Observable { + const options = GetFilterOptions(text, OPTIONS); + return of({ + options + }).pipe(delay(1000)); + } +} diff --git a/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-service/autocomplete-story-service.source.ts b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-service/autocomplete-story-service.source.ts new file mode 100644 index 00000000..08bc55b6 --- /dev/null +++ b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-service/autocomplete-story-service.source.ts @@ -0,0 +1,53 @@ +export const AUTOCOMPLETE_STORY_SERVICE_SOURCE = { + html: ` +
+ + Color + + + +
`, + ts: ` + import { AutocompleteService } from '../autocomplete-story-service/autocomplete.service'; + import { BehaviorSubject } from 'rxjs'; + import { switchMap, tap } from 'rxjs/operators'; + + @Component(...) + export class AutocompleteStoryDefaultComponent { + private text$ = new BehaviorSubject(''); + public form: FormGroup; + public options = string[]; + public isLoading = false; + + constructor( + private formBuilder: FormBuilder, + private autocompleteService: AutocompleteService, + private changeDetector: ChangeDetectorRef + ) { + this.form = this.formBuilder.group({ + autocomplete: '' + }); + this.text$.pipe( + tap(() => { + this.isLoading = true; + }), + switchMap(text => this.autocompleteService.getOptions(text)) + ) + .subscribe(options => { + this.options = options.options; + this.isLoading = false; + this.changeDetector.detectChanges(); + }); + + public onChangeText(text: string) { + this.text$.next(text); + } + } + ` +}; diff --git a/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-service/index.ts b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-service/index.ts new file mode 100644 index 00000000..a022296f --- /dev/null +++ b/projects/elonkit/src/ui/autocomplete/__stories__/autocomplete-story-service/index.ts @@ -0,0 +1 @@ +export { AUTOCOMPLETE_STORY_SERVICE_SOURCE } from './autocomplete-story-service.source'; diff --git a/projects/elonkit/src/ui/autocomplete/autocomplete-option.directive.ts b/projects/elonkit/src/ui/autocomplete/autocomplete-option.directive.ts new file mode 100644 index 00000000..5a9a6a42 --- /dev/null +++ b/projects/elonkit/src/ui/autocomplete/autocomplete-option.directive.ts @@ -0,0 +1,6 @@ +import { Directive } from '@angular/core'; + +@Directive({ + selector: '[esAutocompleteOption]' +}) +export class AutocompleteOptionDirective {} diff --git a/projects/elonkit/src/ui/autocomplete/autocomplete.component.html b/projects/elonkit/src/ui/autocomplete/autocomplete.component.html new file mode 100644 index 00000000..b076bf30 --- /dev/null +++ b/projects/elonkit/src/ui/autocomplete/autocomplete.component.html @@ -0,0 +1,33 @@ + + + + + + {{ option }} + + + + + + + + + + diff --git a/projects/elonkit/src/ui/autocomplete/autocomplete.component.scss b/projects/elonkit/src/ui/autocomplete/autocomplete.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/projects/elonkit/src/ui/autocomplete/autocomplete.component.ts b/projects/elonkit/src/ui/autocomplete/autocomplete.component.ts new file mode 100644 index 00000000..8d314c53 --- /dev/null +++ b/projects/elonkit/src/ui/autocomplete/autocomplete.component.ts @@ -0,0 +1,380 @@ +import { + Component, + ChangeDetectionStrategy, + ChangeDetectorRef, + ViewEncapsulation, + OnInit, + OnDestroy, + Input, + Output, + EventEmitter, + HostBinding, + Optional, + Self, + ViewChild, + ContentChild, + TemplateRef, + InjectionToken, + Inject, + Host +} from '@angular/core'; + +import { NgControl, ControlValueAccessor, FormGroupDirective } from '@angular/forms'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; + +import { Subject, timer } from 'rxjs'; +import { debounce, takeUntil } from 'rxjs/operators'; + +import { MatFormField, MatFormFieldControl } from '@angular/material/form-field'; +import { MatAutocompleteTrigger, MatAutocompleteOrigin } from '@angular/material/autocomplete'; + +import { AutocompleteOptionDirective } from './autocomplete-option.directive'; + +export const ES_AUTOCOMPLETE_DEFAULT_OPTIONS = new InjectionToken( + 'ES_AUTOCOMPLETE_DEFAULT_OPTIONS' +); + +export interface EsAutocompleteDefaultOptions { + debounceTime?: number; + freeInput?: boolean; +} + +@Component({ + selector: 'es-autocomplete', + templateUrl: './autocomplete.component.html', + styleUrls: ['./autocomplete.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + providers: [{ provide: MatFormFieldControl, useExisting: AutocompleteComponent }] +}) +export class AutocompleteComponent + implements MatFormFieldControl, ControlValueAccessor, OnInit, OnDestroy { + /** + * @ignore + */ + public text = ''; + /** + * @ignore + */ + public stateChanges = new Subject(); + private text$ = new Subject(); + + /** + * @ignore + */ + public origin: MatAutocompleteOrigin; + + /** + * Array of options to display. + */ + @Input() public options: any[]; + + /** + * @ignore + */ + @Input() public loading = false; + + /** + * Function that maps an option control value to its display value in the trigger. + */ + @Input() public displayWith = (value?: any): string => { + return value; + }; + + /** + * Function that have chosen value + */ + @Input() public valueFn = (option: any): any => { + return option; + }; + + private _debounceTime: number; + + /** + * Change value after a particular time span has passed + */ + @Input() + public get debounceTime(): number { + return this._debounceTime; + } + public set debounceTime(value: number) { + this._debounceTime = value ?? (this.autocompleteDefaultOptions?.debounceTime || 0); + } + + private _freeInput: boolean; + + /** + * If true the user input is not bound to provided options + */ + @Input() + public get freeInput(): boolean { + return this._freeInput; + } + public set freeInput(value: boolean) { + this._freeInput = value ?? (this.autocompleteDefaultOptions?.freeInput || false); + } + + private _value = ''; + + public get value(): any { + return this._value; + } + + public set value(value: any) { + this._value = value; + this.stateChanges.next(); + } + + private _focused = false; + + public get focused() { + return this._focused; + } + public set focused(value: boolean) { + this._focused = value; + this.stateChanges.next(); + } + + public get empty(): boolean { + return !this.value; + } + + private _required = false; + + /** + * This property is used to indicate whether the input is required + */ + @Input() + public get required() { + return this._required; + } + public set required(value) { + this._required = coerceBooleanProperty(value); + this.stateChanges.next(); + } + + private _disabled = false; + + /** + * This property tells the form field when it should be in the disabled state + */ + @Input() + public get disabled(): boolean { + return this._disabled; + } + public set disabled(value: boolean) { + this._disabled = coerceBooleanProperty(value); + this.stateChanges.next(); + } + + private _placeholder = ''; + + /** + * This property allows us to tell component what to use as a placeholder + */ + @Input() + public get placeholder(): string { + return this._placeholder; + } + + public set placeholder(value: string) { + this._placeholder = value; + this.stateChanges.next(); + } + + public get errorState(): boolean { + const control = this.ngControl; + const form = this.ngForm; + + if (control) { + return control.invalid && (control.touched || form?.submitted); + } + + return false; + } + + /** + * Event emitted when user change text in input + */ + @Output() public changeText = new EventEmitter(); + + @ViewChild('inputChild', { read: MatAutocompleteTrigger, static: true }) + private inputChild: MatAutocompleteTrigger; + + /** + * Template that allows add custom options + */ + @ContentChild(AutocompleteOptionDirective, { read: TemplateRef, static: false }) + public optionTemplate: any; + + private static nextId = 0; + + @HostBinding() public id = `es-autocomplete-${AutocompleteComponent.nextId++}`; + + @HostBinding('attr.aria-describedby') public describedBy = ''; + + @HostBinding('class.floating') + public get shouldLabelFloat(): boolean { + return this.focused || !!this.text; + } + + private destroyed$ = new Subject(); + + /** + * @ignore + */ + constructor( + public changeDetector: ChangeDetectorRef, + @Optional() @Self() public ngControl: NgControl, + @Optional() + public ngForm: FormGroupDirective, + @Optional() + @Inject(ES_AUTOCOMPLETE_DEFAULT_OPTIONS) + private autocompleteDefaultOptions: EsAutocompleteDefaultOptions, + @Optional() @Host() private matFormField: MatFormField + ) { + if (this.ngControl != null) { + this.ngControl.valueAccessor = this; + } + this.debounceTime = autocompleteDefaultOptions?.debounceTime || 0; + this.freeInput = !!autocompleteDefaultOptions?.freeInput; + + this.stateChanges.subscribe(() => { + this.changeDetector.detectChanges(); + }); + } + + /** + * @ignore + */ + public ngOnInit() { + if (this.matFormField) { + this.origin = { + elementRef: this.matFormField.getConnectedOverlayOrigin() + }; + } + + this.text$ + .pipe( + takeUntil(this.destroyed$), + debounce(() => timer(this.debounceTime)) + ) + .subscribe(text => { + this.changeText.emit(text); + }); + } + + /** + * @ignore + */ + public ngOnDestroy() { + this.destroyed$.next(); + this.stateChanges.complete(); + } + + /** + * @ignore + */ + public setDescribedByIds(ids: string[]) { + this.describedBy = ids.join(' '); + } + + /** + * @ignore + */ + public onContainerClick(event: MouseEvent) { + this.openPanel(event); + } + + /** + * @ignore + */ + private openPanel(event: MouseEvent) { + if (!this.focused && !this.disabled && this.inputChild) { + event.stopPropagation(); + this.inputChild.openPanel(); + (this.inputChild as any)._element.nativeElement.focus(); + this.stateChanges.next(); + } + } + + /** + * @ignore + */ + public writeValue(value: any) { + if (value !== undefined) { + this.value = value; + this.text = this.displayWith(this.value); + this.stateChanges.next(); + } + } + + /** + * @ignore + */ + public registerOnChange(onChange: (value: any) => void) { + this.onChange = onChange; + } + + /** + * @ignore + */ + public onChange = (_: any) => {}; + + /** + * @ignore + */ + public registerOnTouched(onTouched: () => void) { + this.onTouched = onTouched; + } + + /** + * @ignore + */ + public onTouched = () => {}; + + /** + * @ignore + */ + public onInput(event: Event) { + const target = event.target as HTMLInputElement; + this.text = target.value; + this.text$.next(this.text); + this.onChange(this.text); + this.stateChanges.next(); + } + + /** + * @ignore + */ + public onFocus() { + this.focused = true; + this.stateChanges.next(); + } + + /** + * @ignore + */ + public onBlur() { + this.onTouched(); + this.focused = false; + + if (!this.freeInput) { + this.text = this.displayWith(this.value); + } + + // this.changeText.emit(this.text); + this.stateChanges.next(); + } + + /** + * @ignore + */ + public onSuggestionSelect(event: Event) { + this.value = event; + this.onChange(this.value); + this.text = this.displayWith(this.value); + + this.stateChanges.next(); + } +} diff --git a/projects/elonkit/src/ui/autocomplete/autocomplete.module.ts b/projects/elonkit/src/ui/autocomplete/autocomplete.module.ts new file mode 100644 index 00000000..88e416da --- /dev/null +++ b/projects/elonkit/src/ui/autocomplete/autocomplete.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatSelectModule } from '@angular/material/select'; + +import { AutocompleteOptionDirective } from './autocomplete-option.directive'; +import { AutocompleteComponent } from './autocomplete.component'; + +@NgModule({ + declarations: [AutocompleteComponent, AutocompleteOptionDirective], + imports: [CommonModule, MatAutocompleteModule, MatInputModule, MatProgressSpinnerModule], + exports: [AutocompleteComponent, AutocompleteOptionDirective, MatSelectModule] +}) +export class AutocompleteModule {} diff --git a/projects/elonkit/src/ui/autocomplete/autocomplete.stories.mdx b/projects/elonkit/src/ui/autocomplete/autocomplete.stories.mdx new file mode 100644 index 00000000..c76afc3b --- /dev/null +++ b/projects/elonkit/src/ui/autocomplete/autocomplete.stories.mdx @@ -0,0 +1,97 @@ +import { Meta, Story, Props } from '@storybook/addon-docs/blocks'; +import { Preview } from '~storybook/components'; + +import { withKnobs, text, number, boolean } from '@storybook/addon-knobs'; +import { action } from '@storybook/addon-actions'; +import { withA11y } from '@storybook/addon-a11y'; + +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; + +import { AutocompleteComponent } from './autocomplete.component'; + +import { AutocompleteStoryDefaultModule } from './__stories__/autocomplete-story-default/autocomplete-story-default.module'; +import { AutocompleteStoryDefaultComponent } from './__stories__/autocomplete-story-default/autocomplete-story-default.component'; +import { AUTOCOMPLETE_STORY_DEFAULT_SOURCE } from './__stories__/autocomplete-story-default/autocomplete-story-default.source'; + +import { AutocompleteStoryServiceModule } from './__stories__/autocomplete-story-service/autocomplete-story-service.module'; +import { AutocompleteStoryServiceComponent } from './__stories__/autocomplete-story-service/autocomplete-story-service.component'; +import { AUTOCOMPLETE_STORY_SERVICE_SOURCE } from './__stories__/autocomplete-story-service/autocomplete-story-service.source'; + +import { AutocompleteStoryCustomModule } from './__stories__/autocomplete-story-custom/autocomplete-story-custom.module'; +import { AutocompleteStoryCustomComponent } from './__stories__/autocomplete-story-custom/autocomplete-story-custom.component'; +import { AUTOCOMPLETE_STORY_CUSTOM_SOURCE } from './__stories__/autocomplete-story-custom/autocomplete-story-custom.source'; + + + +# Autocomplete + +This component demonstrates usage of autocomplete. + +## Demos + +This is a basic story: + + + + {{ + component: AutocompleteStoryDefaultComponent, + moduleMetadata: { + imports: [BrowserAnimationsModule, AutocompleteStoryDefaultModule] + }, + props: { + debounceTime: number('debounceTime', 500), + freeInput: boolean('freeInput', false) + } + }} + + + +This is a story with service: + + + + {{ + template: ``, + moduleMetadata: { + imports: [BrowserAnimationsModule, AutocompleteStoryServiceModule] + } + }} + + + +This is a story with custom options: + + + + {{ + template: ``, + moduleMetadata: { + imports: [BrowserAnimationsModule, AutocompleteStoryCustomModule] + } + }} + + + +## API + + + +## Constants + +Injection token to be used to override the default options for es-autocomplete + +```ts +import { ES_AUTOCOMPLETE_DEFAULT_OPTIONS } from '@elonsoft/elonkit/autocomplete'; + +@NgModule({ + providers: [ + { + provide: ES_AUTOCOMPLETE_DEFAULT_OPTIONS, + useValue: { + debounceTime: 1000, + freeInput: true + } + } + ] +}) +``` diff --git a/projects/elonkit/src/ui/autocomplete/filter-options.ts b/projects/elonkit/src/ui/autocomplete/filter-options.ts new file mode 100644 index 00000000..4debd28e --- /dev/null +++ b/projects/elonkit/src/ui/autocomplete/filter-options.ts @@ -0,0 +1,9 @@ +export function GetFilterOptions(text: string, options: any): any { + const lowerText = text ? text.toLowerCase() : ''; + return options.filter(e => e.toLowerCase().includes(lowerText)); +} + +export function GetFilterOptionsByKey(text: string, options: any, key: string): any { + const lowerText = text ? text.toLowerCase() : ''; + return options.filter(e => e[key].toLowerCase().includes(lowerText)); +} diff --git a/projects/elonkit/src/ui/autocomplete/index.ts b/projects/elonkit/src/ui/autocomplete/index.ts new file mode 100644 index 00000000..7e1a213e --- /dev/null +++ b/projects/elonkit/src/ui/autocomplete/index.ts @@ -0,0 +1 @@ +export * from './public-api'; diff --git a/projects/elonkit/src/ui/autocomplete/ng-package.json b/projects/elonkit/src/ui/autocomplete/ng-package.json new file mode 100644 index 00000000..789c95e4 --- /dev/null +++ b/projects/elonkit/src/ui/autocomplete/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "public-api.ts" + } +} diff --git a/projects/elonkit/src/ui/autocomplete/public-api.ts b/projects/elonkit/src/ui/autocomplete/public-api.ts new file mode 100644 index 00000000..64f2d6cb --- /dev/null +++ b/projects/elonkit/src/ui/autocomplete/public-api.ts @@ -0,0 +1,3 @@ +export * from './autocomplete.module'; +export * from './autocomplete.component'; +export * from './filter-options'; diff --git a/projects/elonkit/src/ui/counter/__specs__/counter.spec.ts b/projects/elonkit/src/ui/counter/__specs__/counter.spec.ts index 22de7713..7ba3529c 100644 --- a/projects/elonkit/src/ui/counter/__specs__/counter.spec.ts +++ b/projects/elonkit/src/ui/counter/__specs__/counter.spec.ts @@ -54,15 +54,14 @@ describe('Counter', () => { // We can emulate user interaction click(button); + expect(onIncrease).toHaveBeenCalledWith(1); expect(getByText('You clicked 1 times')).toBeInTheDocument(); expect(getByText('You clicked 1 times')).toHaveClass('es-counter__count_active'); click(button); + expect(onIncrease).toHaveBeenCalledWith(2); expect(getByText('You clicked 2 times')).toBeInTheDocument(); expect(getByText('You clicked 2 times')).toHaveClass('es-counter__count_active'); - - expect(onIncrease).toHaveBeenNthCalledWith(1, 1); - expect(onIncrease).toHaveBeenNthCalledWith(2, 2); }); });