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 @@
+
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: `
+ `,
+ 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 @@
+
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: `
+ `,
+ 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 @@
+
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: `
+ `,
+ 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);
});
});