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 + + + + {((args, context) => ({ + component: ImageCarouselStoryBasicComponent, + moduleMetadata: { + imports: [ImageCarouselStoryBasicModule] + }, + props: { + ...args, + onView: action('onView'), + onRemove: action('onRemove') + } + })).bind({})} + + + +We can pass `viewSvgIcon` to use custom icon for view button. + + + + {((args, context) => ({ + component: ImageCarouselStoryCustomIconComponent, + moduleMetadata: { + imports: [ImageCarouselStoryCustomIconModule] + }, + props: { + ...args + } + })).bind({})} + + + +## 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