diff --git a/projects/elonkit/src/public-api.ts b/projects/elonkit/src/public-api.ts
index 99ebc1a5..07c0918c 100644
--- a/projects/elonkit/src/public-api.ts
+++ b/projects/elonkit/src/public-api.ts
@@ -5,5 +5,6 @@ export * from './ui/inline-form-field';
export * from './ui/paginator';
export * from './ui/timepicker';
export * from './ui/action-heading';
+export * from './ui/image-carousel';
export * from './ui/locale';
diff --git a/projects/elonkit/src/ui/image-carousel/__specs__/image-carousel.spec.ts b/projects/elonkit/src/ui/image-carousel/__specs__/image-carousel.spec.ts
new file mode 100644
index 00000000..f343de37
--- /dev/null
+++ b/projects/elonkit/src/ui/image-carousel/__specs__/image-carousel.spec.ts
@@ -0,0 +1,108 @@
+import { render } from '@testing-library/angular';
+import { MatIconTestingModule } from '@angular/material/icon/testing';
+
+import { ESImageCarouselComponent } from '../image-carousel.component';
+import { ESImageCarouselModule } from '../image-carousel.module';
+import { filesFixture } from '../fixtures/files.fixture';
+import { ESLocaleService, en, ru } from '../../locale';
+
+describe('Image Carousel', () => {
+ beforeEach(() => {
+ spyOn(ESImageCarouselComponent.prototype, 'elementIsInView').and.returnValue(true);
+ });
+
+ it('Should render all images', async () => {
+ const component = await render(ESImageCarouselComponent, {
+ componentProperties: {
+ files: filesFixture
+ },
+ imports: [ESImageCarouselModule, MatIconTestingModule],
+ excludeComponentDeclaration: true
+ });
+ expect(component.getAllByTestId('image')).toHaveLength(filesFixture.length);
+ });
+
+ it('Should render all buttons', async () => {
+ const component = await render(ESImageCarouselComponent, {
+ componentProperties: {
+ files: filesFixture,
+ canView: true,
+ canRemove: true
+ },
+ imports: [ESImageCarouselModule, MatIconTestingModule],
+ excludeComponentDeclaration: true
+ });
+
+ expect(component.getAllByLabelText(en.imageCarousel.labelRemove)).toHaveLength(
+ filesFixture.length
+ );
+ expect(component.getAllByLabelText(en.imageCarousel.labelView)).toHaveLength(
+ filesFixture.length
+ );
+ expect(component.getByLabelText(en.imageCarousel.labelSlideLeft)).toBeInTheDocument();
+ expect(component.getByLabelText(en.imageCarousel.labelSlideRight)).toBeInTheDocument();
+ });
+
+ it('Should change locale', async () => {
+ const localeService = new ESLocaleService();
+ localeService.register('ru', ru);
+ localeService.use('ru');
+
+ const component = await render(ESImageCarouselComponent, {
+ componentProperties: {
+ files: filesFixture,
+ canView: true,
+ canRemove: true
+ },
+ imports: [ESImageCarouselModule, MatIconTestingModule],
+ providers: [{ provide: ESLocaleService, useValue: localeService }],
+ excludeComponentDeclaration: true
+ });
+ expect(component.getAllByLabelText(ru.imageCarousel.labelRemove)).toHaveLength(
+ filesFixture.length
+ );
+ expect(component.getAllByLabelText(ru.imageCarousel.labelView)).toHaveLength(
+ filesFixture.length
+ );
+ expect(component.getByLabelText(ru.imageCarousel.labelSlideLeft)).toBeInTheDocument();
+ expect(component.getByLabelText(ru.imageCarousel.labelSlideRight)).toBeInTheDocument();
+ });
+
+ it('Should emit view on view button click', async () => {
+ const onView = jest.fn();
+ const component = await render(ESImageCarouselComponent, {
+ componentProperties: {
+ files: filesFixture,
+ canView: true,
+ view: {
+ emit: onView
+ } as any
+ },
+ imports: [ESImageCarouselModule, MatIconTestingModule],
+ excludeComponentDeclaration: true
+ });
+ component.getAllByLabelText(en.imageCarousel.labelView).forEach((btn) => {
+ component.click(btn);
+ });
+ expect(onView).toHaveBeenCalledTimes(filesFixture.length);
+ });
+
+ it('Should emit remove on remove button click', async () => {
+ const onRemove = jest.fn();
+ const component = await render(ESImageCarouselComponent, {
+ componentProperties: {
+ files: filesFixture,
+ canRemove: true,
+ remove: {
+ emit: onRemove
+ } as any
+ },
+ imports: [ESImageCarouselModule, MatIconTestingModule],
+ excludeComponentDeclaration: true
+ });
+ component.getAllByLabelText(en.imageCarousel.labelRemove).forEach((btn) => {
+ component.click(btn);
+ });
+ expect(onRemove).toHaveBeenCalledTimes(filesFixture.length);
+ });
+});
diff --git a/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-basic/image-carousel-story-basic.component.html b/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-basic/image-carousel-story-basic.component.html
new file mode 100644
index 00000000..70d366f1
--- /dev/null
+++ b/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-basic/image-carousel-story-basic.component.html
@@ -0,0 +1,13 @@
+
+
+
diff --git a/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-basic/image-carousel-story-basic.component.scss b/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-basic/image-carousel-story-basic.component.scss
new file mode 100644
index 00000000..c0d5450b
--- /dev/null
+++ b/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-basic/image-carousel-story-basic.component.scss
@@ -0,0 +1,3 @@
+.container {
+ max-width: 600px;
+}
diff --git a/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-basic/image-carousel-story-basic.component.ts b/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-basic/image-carousel-story-basic.component.ts
new file mode 100644
index 00000000..f8215986
--- /dev/null
+++ b/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-basic/image-carousel-story-basic.component.ts
@@ -0,0 +1,26 @@
+import { Component, ChangeDetectionStrategy, Input } from '@angular/core';
+import { ESImageCarouselFile } from '../../image-carousel.types';
+import { filesFixture } from '../../fixtures/files.fixture';
+
+@Component({
+ selector: 'es-image-carousel-basic',
+ templateUrl: './image-carousel-story-basic.component.html',
+ styleUrls: ['./image-carousel-story-basic.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class ImageCarouselStoryBasicComponent {
+ @Input()
+ public imageHeight: number;
+ @Input()
+ public imageWidth: number;
+ @Input()
+ public gap: number;
+ @Input()
+ public canView: boolean;
+ @Input()
+ public canRemove: boolean;
+ @Input()
+ public imageTypes: string;
+
+ public files: ESImageCarouselFile[] = filesFixture;
+}
diff --git a/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-basic/image-carousel-story-basic.module.ts b/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-basic/image-carousel-story-basic.module.ts
new file mode 100644
index 00000000..f3e301aa
--- /dev/null
+++ b/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-basic/image-carousel-story-basic.module.ts
@@ -0,0 +1,12 @@
+import { NgModule } from '@angular/core';
+import { CommonModule } from '@angular/common';
+
+import { ImageCarouselStoryBasicComponent } from './image-carousel-story-basic.component';
+import { ESImageCarouselModule } from '../../image-carousel.module';
+
+@NgModule({
+ declarations: [ImageCarouselStoryBasicComponent],
+ imports: [CommonModule, ESImageCarouselModule],
+ exports: [ImageCarouselStoryBasicComponent]
+})
+export class ImageCarouselStoryBasicModule {}
diff --git a/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-basic/image-carousel-story-basic.source.ts b/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-basic/image-carousel-story-basic.source.ts
new file mode 100644
index 00000000..4b8eac8e
--- /dev/null
+++ b/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-basic/image-carousel-story-basic.source.ts
@@ -0,0 +1,46 @@
+export const IMAGE_CAROUSEL_STORY_BASIC_SOURCE = {
+ ts: `
+ @Component({
+ ...
+ })
+ export class AppComponent {
+ public files = [{
+ id: 1,
+ type: 'image/jpg',
+ file: 'https://dummyimage.com/400x400/405ed6/fff.jpg&text=ES1',
+ name: 'FileName1.jpg',
+ size: 45678,
+ content: null
+ },
+ ...
+ {
+ id: 7,
+ type: 'image/jpg',
+ file: 'https://dummyimage.com/400x400/28B463/fff.jpg&text=ES7',
+ name: 'FileName7.jpg',
+ size: 456789,
+ content: null
+ }]
+ }
+ `,
+ scss: `
+ .container {
+ max-width: 600px;
+ }
+ `,
+ html: `
+
+
+
+ `
+};
diff --git a/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-basic/index.ts b/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-basic/index.ts
new file mode 100644
index 00000000..a7825612
--- /dev/null
+++ b/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-basic/index.ts
@@ -0,0 +1,3 @@
+export { ImageCarouselStoryBasicComponent } from './image-carousel-story-basic.component';
+export { ImageCarouselStoryBasicModule } from './image-carousel-story-basic.module';
+export { IMAGE_CAROUSEL_STORY_BASIC_SOURCE } from './image-carousel-story-basic.source';
diff --git a/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-custom-icon/image-carousel-story-custom-icon.component.html b/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-custom-icon/image-carousel-story-custom-icon.component.html
new file mode 100644
index 00000000..fe9e08e1
--- /dev/null
+++ b/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-custom-icon/image-carousel-story-custom-icon.component.html
@@ -0,0 +1,13 @@
+
+
+
diff --git a/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-custom-icon/image-carousel-story-custom-icon.component.scss b/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-custom-icon/image-carousel-story-custom-icon.component.scss
new file mode 100644
index 00000000..c0d5450b
--- /dev/null
+++ b/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-custom-icon/image-carousel-story-custom-icon.component.scss
@@ -0,0 +1,3 @@
+.container {
+ max-width: 600px;
+}
diff --git a/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-custom-icon/image-carousel-story-custom-icon.component.ts b/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-custom-icon/image-carousel-story-custom-icon.component.ts
new file mode 100644
index 00000000..e4897397
--- /dev/null
+++ b/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-custom-icon/image-carousel-story-custom-icon.component.ts
@@ -0,0 +1,40 @@
+import { Component, ChangeDetectionStrategy, Input } from '@angular/core';
+import { MatIconRegistry } from '@angular/material/icon';
+import { DomSanitizer } from '@angular/platform-browser';
+
+import { ESImageCarouselFile } from '../../image-carousel.types';
+import { filesFixture } from '../../fixtures/files.fixture';
+
+@Component({
+ selector: 'es-image-carousel-custom-icon',
+ templateUrl: './image-carousel-story-custom-icon.component.html',
+ styleUrls: ['./image-carousel-story-custom-icon.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class ImageCarouselStoryCustomIconComponent {
+ @Input()
+ public imageHeight: number;
+ @Input()
+ public imageWidth: number;
+ @Input()
+ public gap: number;
+ @Input()
+ public canView: boolean;
+ @Input()
+ public canRemove: boolean;
+ @Input()
+ public imageTypes: string;
+
+ public files: ESImageCarouselFile[] = filesFixture;
+
+ constructor(private iconRegistry: MatIconRegistry, private sanitizer: DomSanitizer) {
+ iconRegistry.addSvgIcon(
+ 'magnify',
+ sanitizer.bypassSecurityTrustResourceUrl('/icons/image-carousel/magnify.svg')
+ );
+ iconRegistry.addSvgIcon(
+ 'trash-can',
+ sanitizer.bypassSecurityTrustResourceUrl('/icons/image-carousel/trash-can.svg')
+ );
+ }
+}
diff --git a/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-custom-icon/image-carousel-story-custom-icon.module.ts b/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-custom-icon/image-carousel-story-custom-icon.module.ts
new file mode 100644
index 00000000..e04251ca
--- /dev/null
+++ b/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-custom-icon/image-carousel-story-custom-icon.module.ts
@@ -0,0 +1,13 @@
+import { NgModule } from '@angular/core';
+import { CommonModule } from '@angular/common';
+
+import { ImageCarouselStoryCustomIconComponent } from './image-carousel-story-custom-icon.component';
+import { ESImageCarouselModule } from '../../image-carousel.module';
+import { HttpClientModule } from '@angular/common/http';
+
+@NgModule({
+ declarations: [ImageCarouselStoryCustomIconComponent],
+ imports: [CommonModule, ESImageCarouselModule, HttpClientModule],
+ exports: [ImageCarouselStoryCustomIconComponent]
+})
+export class ImageCarouselStoryCustomIconModule {}
diff --git a/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-custom-icon/image-carousel-story-custom-icon.source.ts b/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-custom-icon/image-carousel-story-custom-icon.source.ts
new file mode 100644
index 00000000..351ee326
--- /dev/null
+++ b/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-custom-icon/image-carousel-story-custom-icon.source.ts
@@ -0,0 +1,30 @@
+export const IMAGE_CAROUSEL_STORY_CUSTOM_ICON_SOURCE = {
+ ts: `
+ @Component({
+ ...
+ })
+ export class AppComponent {
+ constructor(private iconRegistry: MatIconRegistry, private sanitizer: DomSanitizer) {
+ iconRegistry.addSvgIcon(
+ 'magnify',
+ sanitizer.bypassSecurityTrustResourceUrl('/icons/magnify.svg')
+ );
+ iconRegistry.addSvgIcon(
+ 'trash-can',
+ sanitizer.bypassSecurityTrustResourceUrl('/icons/image-carousel/trash-can.svg')
+ );
+ }
+ }
+
+ @NgModule({
+ ...
+ imports: [CommonModule, ESImageCarouselModule, HttpClientModule],
+ ...
+ })
+ `,
+ html: `
+
+
+
+ `
+};
diff --git a/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-custom-icon/index.ts b/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-custom-icon/index.ts
new file mode 100644
index 00000000..9bb938c6
--- /dev/null
+++ b/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-custom-icon/index.ts
@@ -0,0 +1,3 @@
+export { ImageCarouselStoryCustomIconComponent } from './image-carousel-story-custom-icon.component';
+export { ImageCarouselStoryCustomIconModule } from './image-carousel-story-custom-icon.module';
+export { IMAGE_CAROUSEL_STORY_CUSTOM_ICON_SOURCE } from './image-carousel-story-custom-icon.source';
diff --git a/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel.stories.mdx b/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel.stories.mdx
new file mode 100644
index 00000000..b61eff7a
--- /dev/null
+++ b/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel.stories.mdx
@@ -0,0 +1,129 @@
+import { Meta, Story, ArgsTable } from '@storybook/addon-docs/blocks';
+import { Canvas } from '~storybook/components';
+
+import { action } from '@storybook/addon-actions';
+
+import { ESImageCarouselComponent } from '..';
+
+import {
+ ImageCarouselStoryBasicModule,
+ ImageCarouselStoryBasicComponent,
+ IMAGE_CAROUSEL_STORY_BASIC_SOURCE
+} from './image-carousel-story-basic';
+
+import {
+ ImageCarouselStoryCustomIconModule,
+ ImageCarouselStoryCustomIconComponent,
+ IMAGE_CAROUSEL_STORY_CUSTOM_ICON_SOURCE
+} from './image-carousel-story-custom-icon';
+
+
+
+# Image Carousel
+
+This component displays images and allows to slide them.
+
+## Demos
+
+
+
+We can pass `viewSvgIcon` to use custom icon for view button.
+
+
+
+## API
+
+
+
+## Interfaces
+
+```ts
+interface ESImageCarouselFile {
+ id?: number;
+ deleted?: boolean;
+ type?: string;
+ base64?: string;
+ file?: string;
+ name: string;
+ size: number;
+ content: File | string;
+}
+```
+
+```ts
+interface ESImageCarouselAction {
+ file: ESImageCarouselFile;
+ index: number;
+}
+```
+
+Image types string should contain types separated by a comma, e.g. `image/png,image/jpg,image/jpeg`
+
+```ts
+interface ESImageCarouselOptions {
+ imageTypes?: string;
+ imageHeight?: number;
+ imageWidth?: number;
+ gap?: number;
+ canRemove?: boolean;
+ canView?: boolean;
+ viewSvgIcon?: string;
+ removeSvgIcon?: string;
+}
+```
+
+## Constants
+
+Injection token that can be used to configure the default options for all components within an app.
+
+```ts
+import { ES_IMAGE_CAROUSEL_DEFAULT_OPTIONS } from '@elonsoft/elonkit/ui/image-carousel';
+
+@NgModule({
+ providers: [
+ {
+ provide: ES_IMAGE_CAROUSEL_DEFAULT_OPTIONS,
+ useValue: {
+ imageHeight: 200,
+ imageWidth: 200,
+ gap: 20
+ }
+ }
+ ]
+})
+```
diff --git a/projects/elonkit/src/ui/image-carousel/fixtures/files.fixture.ts b/projects/elonkit/src/ui/image-carousel/fixtures/files.fixture.ts
new file mode 100644
index 00000000..77e3ac69
--- /dev/null
+++ b/projects/elonkit/src/ui/image-carousel/fixtures/files.fixture.ts
@@ -0,0 +1,60 @@
+import { ESImageCarouselFile } from '../image-carousel.types';
+
+export const filesFixture: ESImageCarouselFile[] = [
+ {
+ id: 1,
+ type: 'image/jpg',
+ file: 'https://dummyimage.com/400x400/405ed6/fff.jpg&text=ES1',
+ name: 'FileName1.jpg',
+ size: 45678,
+ content: null
+ },
+ {
+ id: 2,
+ type: 'image/jpg',
+ file: 'https://dummyimage.com/400x400/28B463/fff.jpg&text=ES2',
+ name: 'FileName2.jpg',
+ size: 456789,
+ content: null
+ },
+ {
+ id: 3,
+ type: 'image/jpg',
+ file: 'https://dummyimage.com/400x400/d6761c/fff.jpg&text=ES3',
+ name: 'FileName3.pdf',
+ size: 4567,
+ content: null
+ },
+ {
+ id: 4,
+ type: 'image/jpg',
+ file: 'https://dummyimage.com/400x400/2dbdb8/fff.jpg&text=ES4',
+ name: 'FileName4.jpg',
+ size: 456,
+ content: null
+ },
+ {
+ id: 5,
+ type: 'image/jpg',
+ file: 'https://dummyimage.com/400x400/F1C40F/fff.jpg&text=ES5',
+ name: 'FileName4.jpg',
+ size: 456,
+ content: null
+ },
+ {
+ id: 6,
+ type: 'image/jpg',
+ file: 'https://dummyimage.com/400x400/E74C3C/fff.jpg&text=ES6',
+ name: 'FileName4.jpg',
+ size: 456,
+ content: null
+ },
+ {
+ id: 7,
+ type: 'image/jpg',
+ file: 'https://dummyimage.com/400x400/A569BD/fff.jpg&text=ES7',
+ name: 'FileName4.jpg',
+ size: 456,
+ content: null
+ }
+];
diff --git a/projects/elonkit/src/ui/image-carousel/image-carousel.component.html b/projects/elonkit/src/ui/image-carousel/image-carousel.component.html
new file mode 100644
index 00000000..b8f652f8
--- /dev/null
+++ b/projects/elonkit/src/ui/image-carousel/image-carousel.component.html
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/projects/elonkit/src/ui/image-carousel/image-carousel.component.scss b/projects/elonkit/src/ui/image-carousel/image-carousel.component.scss
new file mode 100644
index 00000000..a89302e2
--- /dev/null
+++ b/projects/elonkit/src/ui/image-carousel/image-carousel.component.scss
@@ -0,0 +1,76 @@
+.es-image-carousel {
+ display: inline-block;
+ overflow: hidden;
+ position: relative;
+ width: 100%;
+
+ &__items {
+ display: grid;
+ grid-auto-flow: column;
+ transition: transform 1s ease-in-out;
+ }
+
+ &__item {
+ align-items: center;
+ background-position: center;
+ background-size: cover;
+ border-radius: 6px;
+ display: flex;
+ justify-content: center;
+ position: relative;
+
+ &:focus-within &-view,
+ &:focus-within &-remove {
+ opacity: 1;
+ }
+
+ &:hover &-view,
+ &:hover &-remove {
+ opacity: 1;
+ }
+
+ &-view.mat-icon-button {
+ background: rgba(0, 0, 0, 0.54);
+ border-radius: 20px;
+ color: #fff;
+ opacity: 0;
+ transition: opacity 0.2s;
+ }
+
+ &-remove.mat-icon-button {
+ background: rgba(0, 0, 0, 0.54);
+ border-radius: 4px;
+ color: #fff;
+ height: 24px;
+ line-height: 12px;
+ opacity: 0;
+ position: absolute;
+ right: 8px;
+ top: 8px;
+ transition: opacity 0.2s;
+ width: 24px;
+ }
+ }
+
+ &__arrow.mat-icon-button {
+ background: rgba(0, 0, 0, 0.54);
+ border-radius: 4px;
+ color: #fff;
+ position: absolute;
+ visibility: hidden;
+ }
+
+ &__arrow_right.mat-icon-button {
+ right: 16px;
+ top: calc(50% - 40px / 2);
+ }
+
+ &__arrow_left.mat-icon-button {
+ left: 16px;
+ top: calc(50% - 40px / 2);
+ }
+
+ &__arrow_enabled.mat-icon-button {
+ visibility: visible;
+ }
+}
diff --git a/projects/elonkit/src/ui/image-carousel/image-carousel.component.ts b/projects/elonkit/src/ui/image-carousel/image-carousel.component.ts
new file mode 100644
index 00000000..ffbafaa3
--- /dev/null
+++ b/projects/elonkit/src/ui/image-carousel/image-carousel.component.ts
@@ -0,0 +1,302 @@
+import {
+ Component,
+ Input,
+ ViewChild,
+ ElementRef,
+ Output,
+ EventEmitter,
+ ChangeDetectionStrategy,
+ ViewEncapsulation,
+ OnInit,
+ InjectionToken,
+ Optional,
+ Inject,
+ HostListener
+} from '@angular/core';
+import { coerceNumberProperty, coerceBooleanProperty } from '@angular/cdk/coercion';
+
+import {
+ ESImageCarouselFile,
+ ESImageCarouselAction,
+ ESImageCarouselOptions
+} from './image-carousel.types';
+import { validateFileType } from '~utils/validate-file-type';
+import { ESLocaleService, ESLocale } from '../locale';
+import { Observable } from 'rxjs';
+
+export const ES_IMAGE_CAROUSEL_DEFAULT_OPTIONS = new InjectionToken(
+ 'ES_IMAGE_CAROUSEL_DEFAULT_OPTIONS'
+);
+
+@Component({
+ selector: 'es-image-carousel',
+ templateUrl: './image-carousel.component.html',
+ styleUrls: ['./image-carousel.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ encapsulation: ViewEncapsulation.None
+})
+export class ESImageCarouselComponent implements OnInit {
+ /**
+ * Array of files to display.
+ */
+ @Input()
+ public get files(): ESImageCarouselFile[] {
+ return this._files;
+ }
+ public set files(value: ESImageCarouselFile[]) {
+ this._files = value.filter((file) => validateFileType(file, this.imageTypes));
+ }
+ private _files: ESImageCarouselFile[];
+
+ /**
+ * File types to be considered as image separatedby a comma, e.g. `image/png,image/jpg,image/jpeg`.
+ * Defaults to `image/*`.
+ */
+ @Input()
+ public get imageTypes(): string {
+ return this._imageTypes;
+ }
+ public set imageTypes(value: string) {
+ this._imageTypes = value ?? this.defaultOptions?.imageTypes ?? 'image/*';
+ }
+ private _imageTypes: string;
+
+ /**
+ * Defines height of each image in pixels.
+ */
+ @Input()
+ public get imageHeight(): number {
+ return this._imageHeight;
+ }
+ public set imageHeight(value: number) {
+ this._imageHeight = coerceNumberProperty(value, 160);
+ }
+ private _imageHeight: number;
+
+ /**
+ * Defines width of each image in pixels.
+ */
+ @Input()
+ public get imageWidth(): number {
+ return this._imageWidth;
+ }
+ public set imageWidth(value: number) {
+ this._imageWidth = coerceNumberProperty(value, 160);
+ }
+ private _imageWidth: number;
+
+ /**
+ * Defines gap between images in pixels.
+ */
+ @Input()
+ public get gap(): number {
+ return this._gap;
+ }
+ public set gap(value: number) {
+ this._gap = coerceNumberProperty(value, 16);
+ }
+ private _gap: number;
+
+ /**
+ * Defines whether remove buttons should be rendered for images.
+ */
+ @Input()
+ public get canRemove(): boolean {
+ return this._canRemove;
+ }
+ public set canRemove(value: boolean) {
+ this._canRemove = coerceBooleanProperty(value);
+ }
+ private _canRemove: boolean;
+
+ /**
+ * Defines whether view buttons should be rendered for images.
+ */
+ @Input()
+ public get canView(): boolean {
+ return this._canView;
+ }
+ public set canView(value: boolean) {
+ this._canView = coerceBooleanProperty(value);
+ }
+ private _canView: boolean;
+
+ /**
+ * Defines custom svg icon to render for view buttons.
+ */
+ @Input()
+ public get viewSvgIcon(): string {
+ return this._viewSvgIcon;
+ }
+ public set viewSvgIcon(value: string) {
+ this._viewSvgIcon = value ?? this.defaultOptions?.viewSvgIcon;
+ }
+ private _viewSvgIcon: string;
+
+ /**
+ * Defines custom svg icon to render for remove buttons.
+ */
+ @Input()
+ public get removeSvgIcon(): string {
+ return this._removeSvgIcon;
+ }
+ public set removeSvgIcon(value: string) {
+ this._removeSvgIcon = value ?? this.defaultOptions?.removeSvgIcon;
+ }
+ private _removeSvgIcon: string;
+
+ /**
+ * Object with `ESImageCarouselAction` type is emitted.
+ */
+ @Output()
+ public view: EventEmitter = new EventEmitter();
+
+ /**
+ * Object with `ESImageCarouselAction` type is emitted.
+ */
+ @Output()
+ public remove: EventEmitter = new EventEmitter();
+
+ @ViewChild('carousel', { static: true })
+ private carousel: ElementRef;
+
+ private carouselPosition = 0;
+ private slideCount = 0;
+ private maxSlideCount: number;
+
+ /**
+ * @internal
+ * @ignore
+ */
+ @HostListener('window:resize') public onResize() {
+ const fitInViewCount = Math.floor(
+ this.carousel.nativeElement.clientWidth / (this.imageWidth + this.gap)
+ );
+ this.maxSlideCount = this.files.length - fitInViewCount;
+ }
+
+ /**
+ * @internal
+ * @ignore
+ */
+ public locale$: Observable;
+
+ /**
+ * @internal
+ * @ignore
+ */
+ constructor(
+ @Optional()
+ @Inject(ES_IMAGE_CAROUSEL_DEFAULT_OPTIONS)
+ private defaultOptions: ESImageCarouselOptions,
+ private localeService: ESLocaleService
+ ) {
+ this.gap = this.defaultOptions?.gap;
+ this.imageHeight = this.defaultOptions?.imageHeight;
+ this.imageWidth = this.defaultOptions?.imageWidth;
+ this.imageTypes = this.defaultOptions?.imageTypes;
+ this.canRemove = this.defaultOptions?.canRemove;
+ this.canView = this.defaultOptions?.canView;
+ this.viewSvgIcon = this.defaultOptions?.viewSvgIcon;
+ this.locale$ = this.localeService.locale();
+ }
+
+ /**
+ * @internal
+ * @ignore
+ */
+ public ngOnInit(): void {
+ const fitInViewCount = Math.floor(
+ this.carousel.nativeElement.clientWidth / (this.imageWidth + this.gap)
+ );
+ this.maxSlideCount = this.files.length - fitInViewCount;
+ }
+
+ /**
+ * @internal
+ * @ignore
+ */
+ public getImage(file: ESImageCarouselFile): string {
+ return file.id ? file.file : file.base64;
+ }
+
+ /**
+ * @internal
+ * @ignore
+ */
+ public elementIsInView(index: number): boolean {
+ const fitInViewCount = Math.floor(
+ this.carousel.nativeElement.clientWidth / (this.imageWidth + this.gap)
+ );
+ return index + 1 - this.slideCount <= 0 ? false : index + 1 - this.slideCount <= fitInViewCount;
+ }
+
+ /**
+ * @internal
+ * @ignore
+ */
+ public slideRight(): void {
+ this.slideCount++;
+ this.carouselPosition = this.carouselPosition - (this.imageWidth + this.gap);
+ }
+
+ /**
+ * @internal
+ * @ignore
+ */
+ public slideLeft(): void {
+ this.slideCount--;
+ this.carouselPosition = this.carouselPosition + (this.imageWidth + this.gap);
+ }
+
+ /**
+ * @internal
+ * @ignore
+ */
+ public get carouselWidth(): number {
+ // Last file doesn't have a gap
+ const gapWidth = (this.files.length - 1) * this.gap;
+ const totalWidth = this.files.length * this.imageWidth + gapWidth;
+ return totalWidth;
+ }
+
+ /**
+ * @internal
+ * @ignore
+ */
+ public get getTranslateX(): string {
+ return `translateX(${this.carouselPosition}px)`;
+ }
+
+ /**
+ * @internal
+ * @ignore
+ */
+ public get canScrollRight(): boolean {
+ return this.slideCount < this.maxSlideCount;
+ }
+
+ /**
+ * @internal
+ * @ignore
+ */
+ public get canScrollLeft(): boolean {
+ return Math.abs(this.carouselPosition) >= this.imageWidth + this.gap;
+ }
+
+ /**
+ * @internal
+ * @ignore
+ */
+ public viewImage(file: ESImageCarouselAction): void {
+ this.view.emit(file);
+ }
+
+ /**
+ * @internal
+ * @ignore
+ */
+ public removeImage(file: ESImageCarouselAction): void {
+ this.remove.emit(file);
+ }
+}
diff --git a/projects/elonkit/src/ui/image-carousel/image-carousel.module.ts b/projects/elonkit/src/ui/image-carousel/image-carousel.module.ts
new file mode 100644
index 00000000..bb87ed03
--- /dev/null
+++ b/projects/elonkit/src/ui/image-carousel/image-carousel.module.ts
@@ -0,0 +1,13 @@
+import { NgModule } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { MatIconModule } from '@angular/material/icon';
+import { MatButtonModule } from '@angular/material/button';
+
+import { ESImageCarouselComponent } from './image-carousel.component';
+
+@NgModule({
+ declarations: [ESImageCarouselComponent],
+ imports: [CommonModule, MatIconModule, MatButtonModule],
+ exports: [ESImageCarouselComponent]
+})
+export class ESImageCarouselModule {}
diff --git a/projects/elonkit/src/ui/image-carousel/image-carousel.types.ts b/projects/elonkit/src/ui/image-carousel/image-carousel.types.ts
new file mode 100644
index 00000000..4d7c90bf
--- /dev/null
+++ b/projects/elonkit/src/ui/image-carousel/image-carousel.types.ts
@@ -0,0 +1,26 @@
+export interface ESImageCarouselFile {
+ id?: number;
+ deleted?: boolean;
+ type?: string;
+ base64?: string;
+ file?: string;
+ name: string;
+ size: number;
+ content: File | string;
+}
+
+export interface ESImageCarouselAction {
+ file: ESImageCarouselFile;
+ index: number;
+}
+
+export interface ESImageCarouselOptions {
+ imageTypes?: string;
+ imageHeight?: number;
+ imageWidth?: number;
+ gap?: number;
+ canRemove?: boolean;
+ canView?: boolean;
+ viewSvgIcon?: string;
+ removeSvgIcon?: string;
+}
diff --git a/projects/elonkit/src/ui/image-carousel/index.ts b/projects/elonkit/src/ui/image-carousel/index.ts
new file mode 100644
index 00000000..7e1a213e
--- /dev/null
+++ b/projects/elonkit/src/ui/image-carousel/index.ts
@@ -0,0 +1 @@
+export * from './public-api';
diff --git a/projects/elonkit/src/ui/image-carousel/ng-package.json b/projects/elonkit/src/ui/image-carousel/ng-package.json
new file mode 100644
index 00000000..789c95e4
--- /dev/null
+++ b/projects/elonkit/src/ui/image-carousel/ng-package.json
@@ -0,0 +1,5 @@
+{
+ "lib": {
+ "entryFile": "public-api.ts"
+ }
+}
diff --git a/projects/elonkit/src/ui/image-carousel/public-api.ts b/projects/elonkit/src/ui/image-carousel/public-api.ts
new file mode 100644
index 00000000..a2119766
--- /dev/null
+++ b/projects/elonkit/src/ui/image-carousel/public-api.ts
@@ -0,0 +1,10 @@
+export { ESImageCarouselModule } from './image-carousel.module';
+export {
+ ESImageCarouselComponent,
+ ES_IMAGE_CAROUSEL_DEFAULT_OPTIONS
+} from './image-carousel.component';
+export {
+ ESImageCarouselFile,
+ ESImageCarouselAction,
+ ESImageCarouselOptions
+} from './image-carousel.types';
diff --git a/projects/elonkit/src/ui/locale/locales/en.ts b/projects/elonkit/src/ui/locale/locales/en.ts
index ba3b5730..d1870e39 100644
--- a/projects/elonkit/src/ui/locale/locales/en.ts
+++ b/projects/elonkit/src/ui/locale/locales/en.ts
@@ -22,5 +22,11 @@ export const en = {
labelHH: 'HH',
labelMM: 'MM',
labelSS: 'SS'
+ },
+ imageCarousel: {
+ labelView: 'View',
+ labelRemove: 'Remove',
+ labelSlideRight: 'Slide right',
+ labelSlideLeft: 'Slide left'
}
};
diff --git a/projects/elonkit/src/ui/locale/locales/ru.ts b/projects/elonkit/src/ui/locale/locales/ru.ts
index f7aa4a18..58ae1b1d 100644
--- a/projects/elonkit/src/ui/locale/locales/ru.ts
+++ b/projects/elonkit/src/ui/locale/locales/ru.ts
@@ -22,5 +22,11 @@ export const ru = {
labelHH: 'ЧЧ',
labelMM: 'ММ',
labelSS: 'СС'
+ },
+ imageCarousel: {
+ labelView: 'Смотреть',
+ labelRemove: 'Удалить',
+ labelSlideRight: 'Передвинуть вправо',
+ labelSlideLeft: 'Передвинуть влево'
}
};
diff --git a/projects/elonkit/storybook/assets/icons/image-carousel/magnify.svg b/projects/elonkit/storybook/assets/icons/image-carousel/magnify.svg
new file mode 100644
index 00000000..0ba30177
--- /dev/null
+++ b/projects/elonkit/storybook/assets/icons/image-carousel/magnify.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/projects/elonkit/storybook/assets/icons/image-carousel/trash-can.svg b/projects/elonkit/storybook/assets/icons/image-carousel/trash-can.svg
new file mode 100644
index 00000000..27380abc
--- /dev/null
+++ b/projects/elonkit/storybook/assets/icons/image-carousel/trash-can.svg
@@ -0,0 +1 @@
+
\ No newline at end of file