From 558aad417ac8b1d3a46f90ab6ab3a2ecb72c978d Mon Sep 17 00:00:00 2001 From: Sergei Klimenko Date: Thu, 9 Jul 2020 17:36:51 +0300 Subject: [PATCH 1/9] feat(image-carousel): add image-carousel component --- .../image-carousel/__specs__/counter.spec.ts | 68 +++++++ .../custom-dialog/custom-dialog-ref.ts | 17 ++ .../custom-dialog/custom-dialog.service.ts | 84 +++++++++ .../custom-dialog/custom-dialog.tokens.ts | 3 + .../image-dialog/image-dialog.component.html | 17 ++ .../image-dialog/image-dialog.component.scss | 60 ++++++ .../image-dialog/image-dialog.component.ts | 49 +++++ .../image-dialog/image-dialog.module.ts | 15 ++ .../ui/image-carousel/custom-dialog/index.ts | 2 + .../image-carousel.component.html | 49 +++++ .../image-carousel.component.scss | 93 +++++++++ .../image-carousel.component.ts | 178 ++++++++++++++++++ .../image-carousel/image-carousel.module.ts | 15 ++ .../ui/image-carousel/image-carousel.types.ts | 15 ++ .../elonkit/src/ui/image-carousel/index.ts | 1 + .../src/ui/image-carousel/ng-package.json | 5 + .../src/ui/image-carousel/public-api.ts | 2 + 17 files changed, 673 insertions(+) create mode 100644 projects/elonkit/src/ui/image-carousel/__specs__/counter.spec.ts create mode 100644 projects/elonkit/src/ui/image-carousel/custom-dialog/custom-dialog-ref.ts create mode 100644 projects/elonkit/src/ui/image-carousel/custom-dialog/custom-dialog.service.ts create mode 100644 projects/elonkit/src/ui/image-carousel/custom-dialog/custom-dialog.tokens.ts create mode 100644 projects/elonkit/src/ui/image-carousel/custom-dialog/image-dialog/image-dialog.component.html create mode 100644 projects/elonkit/src/ui/image-carousel/custom-dialog/image-dialog/image-dialog.component.scss create mode 100644 projects/elonkit/src/ui/image-carousel/custom-dialog/image-dialog/image-dialog.component.ts create mode 100644 projects/elonkit/src/ui/image-carousel/custom-dialog/image-dialog/image-dialog.module.ts create mode 100644 projects/elonkit/src/ui/image-carousel/custom-dialog/index.ts create mode 100644 projects/elonkit/src/ui/image-carousel/image-carousel.component.html create mode 100644 projects/elonkit/src/ui/image-carousel/image-carousel.component.scss create mode 100644 projects/elonkit/src/ui/image-carousel/image-carousel.component.ts create mode 100644 projects/elonkit/src/ui/image-carousel/image-carousel.module.ts create mode 100644 projects/elonkit/src/ui/image-carousel/image-carousel.types.ts create mode 100644 projects/elonkit/src/ui/image-carousel/index.ts create mode 100644 projects/elonkit/src/ui/image-carousel/ng-package.json create mode 100644 projects/elonkit/src/ui/image-carousel/public-api.ts diff --git a/projects/elonkit/src/ui/image-carousel/__specs__/counter.spec.ts b/projects/elonkit/src/ui/image-carousel/__specs__/counter.spec.ts new file mode 100644 index 00000000..22de7713 --- /dev/null +++ b/projects/elonkit/src/ui/image-carousel/__specs__/counter.spec.ts @@ -0,0 +1,68 @@ +import { render } from '@testing-library/angular'; + +import { ESCounterModule, ESCounterComponent } from '..'; + +describe('Counter', () => { + // This is an example of a basic test setup + it('Should render component', async () => { + const { getByText } = await render(ESCounterComponent, { + imports: [ESCounterModule], + excludeComponentDeclaration: true + }); + + // We can use getByText in order to find DOM-node by text + expect(getByText('Counter')).toBeInTheDocument(); + }); + + it('Should render correct heading', async () => { + const { fixture, getByText } = await render(ESCounterComponent, { + // We can pass inputs... + componentProperties: { heading: 'Heading 1' }, + imports: [ESCounterModule], + excludeComponentDeclaration: true + }); + + expect(getByText('Heading 1')).toBeInTheDocument(); + + // ... and change them later on + fixture.componentInstance.heading = 'Heading 2'; + fixture.componentInstance.changeDetector.detectChanges(); + + expect(getByText('Heading 2')).toBeInTheDocument(); + }); + + // This is an example of a actually usefull test + it('Should increase value on click', async () => { + const onIncrease = jest.fn(); + + const { getByText, click } = await render(ESCounterComponent, { + // We can pass outputs + componentProperties: { + increase: { + emit: onIncrease + } as any + }, + imports: [ESCounterModule], + excludeComponentDeclaration: true + }); + + const button = getByText('CLICK'); + + expect(getByText('You clicked 0 times')).toBeInTheDocument(); + expect(getByText('You clicked 0 times')).not.toHaveClass('es-counter__count_active'); + + // We can emulate user interaction + click(button); + + expect(getByText('You clicked 1 times')).toBeInTheDocument(); + expect(getByText('You clicked 1 times')).toHaveClass('es-counter__count_active'); + + click(button); + + 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); + }); +}); diff --git a/projects/elonkit/src/ui/image-carousel/custom-dialog/custom-dialog-ref.ts b/projects/elonkit/src/ui/image-carousel/custom-dialog/custom-dialog-ref.ts new file mode 100644 index 00000000..007d4162 --- /dev/null +++ b/projects/elonkit/src/ui/image-carousel/custom-dialog/custom-dialog-ref.ts @@ -0,0 +1,17 @@ +import { OverlayRef } from '@angular/cdk/overlay'; +import { BehaviorSubject } from 'rxjs'; + +export class CustomDialogRef { + private afterClosed$ = new BehaviorSubject(null); + + constructor(private overlay: OverlayRef) {} + + public close(data?: any): void { + this.overlay.dispose(); + this.afterClosed$.next(data); + } + + public afterClosed() { + return this.afterClosed$.asObservable(); + } +} diff --git a/projects/elonkit/src/ui/image-carousel/custom-dialog/custom-dialog.service.ts b/projects/elonkit/src/ui/image-carousel/custom-dialog/custom-dialog.service.ts new file mode 100644 index 00000000..2253f996 --- /dev/null +++ b/projects/elonkit/src/ui/image-carousel/custom-dialog/custom-dialog.service.ts @@ -0,0 +1,84 @@ +import { Injectable, Injector, ComponentRef } from '@angular/core'; +import { Overlay, OverlayRef, OverlayConfig } from '@angular/cdk/overlay'; +import { ComponentPortal, PortalInjector, ComponentType } from '@angular/cdk/portal'; + +import { CustomDialogRef } from './custom-dialog-ref'; +import { CUSTOM_DIALOG_DATA } from './custom-dialog.tokens'; + +interface CustomDialogConfig { + panelClass?: string; + hasBackdrop?: boolean; + backdropClass?: string; + data?: any; +} + +const DEFAULT_CONFIG: CustomDialogConfig = { + hasBackdrop: true +}; + +@Injectable() +export class CustomDialogService { + constructor(private injector: Injector, private overlay: Overlay) {} + + public open(component: ComponentType, config: CustomDialogConfig = {}) { + const dialogConfig = { ...DEFAULT_CONFIG, ...config }; + const overlayRef = this.createOverlay(dialogConfig); + const dialogRef = new CustomDialogRef(overlayRef); + const overlayComponent = this.attachDialogContainer( + component, + overlayRef, + dialogConfig, + dialogRef + ); + + overlayRef.backdropClick().subscribe(_ => dialogRef.close()); + + return dialogRef; + } + + private createOverlay(config: CustomDialogConfig) { + const overlayConfig = this.getOverlayConfig(config); + return this.overlay.create(overlayConfig); + } + + private attachDialogContainer( + component: ComponentType, + overlayRef: OverlayRef, + config: CustomDialogConfig, + dialogRef: CustomDialogRef + ) { + const injector = this.createInjector(config, dialogRef); + + const containerPortal = new ComponentPortal(component, null, injector); + const containerRef: ComponentRef = overlayRef.attach(containerPortal); + + return containerRef.instance; + } + + private createInjector(config: CustomDialogConfig, dialogRef: CustomDialogRef): PortalInjector { + const injectionTokens = new WeakMap(); + + injectionTokens.set(CustomDialogRef, dialogRef); + injectionTokens.set(CUSTOM_DIALOG_DATA, config.data); + + return new PortalInjector(this.injector, injectionTokens); + } + + private getOverlayConfig(config: CustomDialogConfig): OverlayConfig { + const positionStrategy = this.overlay + .position() + .global() + .centerHorizontally() + .centerVertically(); + + const overlayConfig = new OverlayConfig({ + hasBackdrop: config.hasBackdrop, + backdropClass: config.backdropClass, + panelClass: config.panelClass, + scrollStrategy: this.overlay.scrollStrategies.block(), + positionStrategy + }); + + return overlayConfig; + } +} diff --git a/projects/elonkit/src/ui/image-carousel/custom-dialog/custom-dialog.tokens.ts b/projects/elonkit/src/ui/image-carousel/custom-dialog/custom-dialog.tokens.ts new file mode 100644 index 00000000..e1907e79 --- /dev/null +++ b/projects/elonkit/src/ui/image-carousel/custom-dialog/custom-dialog.tokens.ts @@ -0,0 +1,3 @@ +import { InjectionToken } from '@angular/core'; + +export const CUSTOM_DIALOG_DATA = new InjectionToken('CUSTOM_DIALOG_DATA'); diff --git a/projects/elonkit/src/ui/image-carousel/custom-dialog/image-dialog/image-dialog.component.html b/projects/elonkit/src/ui/image-carousel/custom-dialog/image-dialog/image-dialog.component.html new file mode 100644 index 00000000..e9af4916 --- /dev/null +++ b/projects/elonkit/src/ui/image-carousel/custom-dialog/image-dialog/image-dialog.component.html @@ -0,0 +1,17 @@ + +
+
+
+
+
+ delete + Delete +
+
+ diff --git a/projects/elonkit/src/ui/image-carousel/custom-dialog/image-dialog/image-dialog.component.scss b/projects/elonkit/src/ui/image-carousel/custom-dialog/image-dialog/image-dialog.component.scss new file mode 100644 index 00000000..bc5c7c91 --- /dev/null +++ b/projects/elonkit/src/ui/image-carousel/custom-dialog/image-dialog/image-dialog.component.scss @@ -0,0 +1,60 @@ +:host { + align-items: center; + display: flex; + justify-content: center; + left: 0; + top: 0; +} + +.image-modal { + display: block; + height: 60%; + justify-content: center; + position: absolute; + width: 30%; + + &__image { + background-position: center top; + background-repeat: no-repeat; + background-size: contain; + height: 100%; + width: 100%; + } + + &__loading { + z-index: 1; + } + + &__close { + background: rgba(0, 0, 0, 0.54); + border-radius: 4px; + color: #fff; + height: 48px; + position: absolute; + right: 16px; + top: 16px; + width: 48px; + } +} + +.actions { + bottom: 48px; + margin: auto; + position: absolute; + z-index: 1; + + &__action { + align-items: center; + background: rgba(0, 0, 0, 0.54); + border-radius: 4px; + color: #fff; + cursor: pointer; + display: flex; + font-family: Roboto; + padding: 8px 16px; + } + + &__delete-icon { + margin-right: 8px; + } +} diff --git a/projects/elonkit/src/ui/image-carousel/custom-dialog/image-dialog/image-dialog.component.ts b/projects/elonkit/src/ui/image-carousel/custom-dialog/image-dialog/image-dialog.component.ts new file mode 100644 index 00000000..d8604dcd --- /dev/null +++ b/projects/elonkit/src/ui/image-carousel/custom-dialog/image-dialog/image-dialog.component.ts @@ -0,0 +1,49 @@ +import { Component, Inject, OnInit } from '@angular/core'; +import { CustomDialogRef } from '../custom-dialog-ref'; +import { CUSTOM_DIALOG_DATA } from '../custom-dialog.tokens'; +import { Observable } from 'rxjs'; +import { DomSanitizer } from '@angular/platform-browser'; + +@Component({ + selector: 'es-image-dialog', + templateUrl: './image-dialog.component.html', + styleUrls: ['./image-dialog.component.scss'] +}) +export class ImageDialogComponent implements OnInit { + public imageUrl: string; + public isLoading = false; + public deleteImageFn; + + constructor( + private dialogRef: CustomDialogRef, + @Inject(CUSTOM_DIALOG_DATA) public data: any, + private sanitizer: DomSanitizer + ) {} + + public ngOnInit(): void { + this.imageUrl = this.data.imageUrl; + this.deleteImageFn = this.data.deleteImageFn; + } + + public closeImageDialog() { + this.dialogRef.close({ imageUrl: this.imageUrl }); + } + + public deleteImage() { + const obs = this.deleteImageFn(); + this.isLoading = true; + obs.pipe().subscribe( + () => { + this.isLoading = false; + this.dialogRef.close({ imageUrl: null }); + }, + () => { + this.isLoading = false; + } + ); + } + + public getSanitizedImage() { + return this.sanitizer.bypassSecurityTrustStyle(`url('${this.imageUrl}')`); + } +} diff --git a/projects/elonkit/src/ui/image-carousel/custom-dialog/image-dialog/image-dialog.module.ts b/projects/elonkit/src/ui/image-carousel/custom-dialog/image-dialog/image-dialog.module.ts new file mode 100644 index 00000000..d3cd7210 --- /dev/null +++ b/projects/elonkit/src/ui/image-carousel/custom-dialog/image-dialog/image-dialog.module.ts @@ -0,0 +1,15 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; + +import { ImageDialogComponent } from './image-dialog.component'; + +@NgModule({ + declarations: [ImageDialogComponent], + imports: [CommonModule, MatProgressSpinnerModule, MatButtonModule, MatIconModule], + exports: [ImageDialogComponent], + entryComponents: [ImageDialogComponent] +}) +export class ImageDialogModule {} diff --git a/projects/elonkit/src/ui/image-carousel/custom-dialog/index.ts b/projects/elonkit/src/ui/image-carousel/custom-dialog/index.ts new file mode 100644 index 00000000..3c2b2d21 --- /dev/null +++ b/projects/elonkit/src/ui/image-carousel/custom-dialog/index.ts @@ -0,0 +1,2 @@ +export { CustomDialogService } from './custom-dialog.service'; +export { CUSTOM_DIALOG_DATA } from './custom-dialog.tokens'; 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..82b74c2c --- /dev/null +++ b/projects/elonkit/src/ui/image-carousel/image-carousel.component.html @@ -0,0 +1,49 @@ +
+
+ +
+
+ close +
+ +
+ + download +
+
+
+ remove_red_eye +
+
+
+
+
+ arrow_forward +
+ +
+ arrow_back +
+
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..00e028b3 --- /dev/null +++ b/projects/elonkit/src/ui/image-carousel/image-carousel.component.scss @@ -0,0 +1,93 @@ +.image-scroller { + overflow: hidden; + position: relative; + + &__items { + left: 0; + position: relative; + transition: all 1s; + white-space: nowrap; + } + + &__item { + background-position: center; + background-size: cover; + border-radius: 6px; + cursor: pointer; + display: inline-block; + max-width: 100%; + min-width: 0; + position: relative; + + &:not(:last-child) { + margin-right: 16px; + } + + &-view { + align-items: center; + background-color: rgba(0, 0, 0, 0.54); + border-radius: 50%; + cursor: pointer; + display: flex; + height: 48px; + justify-content: center; + left: calc(50% - 24px); + position: absolute; + top: calc(50% - 24px); + visibility: hidden; + width: 48px; + + .mat-icon { + color: #fff; + } + } + + &-icon { + align-items: center; + background-color: rgba(0, 0, 0, 0.38); + border-radius: 6px; + display: flex; + height: 32px; + justify-content: center; + position: absolute; + right: 8px; + top: 8px; + visibility: hidden; + width: 32px; + + .mat-icon { + color: #fff; + } + } + + &:hover &-icon, + &:hover &-view { + visibility: visible !important; + } + } + + &__arrow { + align-items: center; + background-color: rgba(0, 0, 0, 0.54); + border-radius: 6px; + cursor: pointer; + display: flex; + height: 40px; + justify-content: center; + position: absolute; + top: calc(50% - 40px / 2); + width: 40px; + + &_left { + left: 16px; + } + + &_right { + right: 16px; + } + + .mat-icon { + color: #fff; + } + } +} 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..17602748 --- /dev/null +++ b/projects/elonkit/src/ui/image-carousel/image-carousel.component.ts @@ -0,0 +1,178 @@ +import { + Component, + Input, + ViewChild, + ElementRef, + Output, + EventEmitter, + AfterViewChecked, + ChangeDetectorRef, + ChangeDetectionStrategy, + ViewEncapsulation +} from '@angular/core'; +import { DomSanitizer, SafeStyle } from '@angular/platform-browser'; + +import { CustomDialogService } from './custom-dialog'; +import { ImageDialogComponent } from './custom-dialog/image-dialog/image-dialog.component'; +import { ESImageCarouselFile, ESImageCarouselRemoveAction } from './image-carousel.types'; + +@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 AfterViewChecked { + public imageTypes = ['image/png', 'image/jpg', 'image/jpeg', 'image']; + + @Input() + public files: ESImageCarouselFile[]; + + @Input() + public imgHeight = '160px'; + + @Input() + public imgWidth = '160px'; + + @Input() + public canRemove = true; + + @Input() + public canDownload = false; + + @Output() + public remove: EventEmitter = new EventEmitter(); + + @ViewChild('scroller', { static: true }) + public scroller: ElementRef; + + constructor( + private customDialogService: CustomDialogService, + private sanitizer: DomSanitizer, + private cdRef: ChangeDetectorRef + ) {} + + public ngAfterViewChecked(): void { + this.cdRef.markForCheck(); + } + + public getImage(file: ESImageCarouselFile): string { + return file.id ? file.file : file.base64; + } + + public getSanitizedImage(file: ESImageCarouselFile): SafeStyle { + return this.sanitizer.bypassSecurityTrustStyle(`url('${this.getImage(file)}')`); + } + + public scrollToRight(): void { + const scrollValues = this.getValuesToScroll(); + if (this.isRightButton()) { + let left = +this.pruneString(scrollValues.el.style.left); + if (scrollValues.countOfElOutView > scrollValues.el.clientWidth) { + left = + left - + (scrollValues.countOfElOutView + scrollValues.restOfCountInView) * scrollValues.imgWidth; + } else { + left = + left - + (scrollValues.countOfElInView + scrollValues.restOfCountInView) * scrollValues.imgWidth + + scrollValues.margin; + } + + left = + Math.abs(left) + scrollValues.el.clientWidth > scrollValues.el.scrollWidth + ? -(scrollValues.el.scrollWidth - scrollValues.el.clientWidth) + : left; + + scrollValues.el.style.left = `${left}px`; + } + } + + public isRightButton(): boolean { + const el = this.scroller.nativeElement; + const left = Math.abs(+this.pruneString(el.style.left)) + el.offsetWidth; + return left !== el.scrollWidth; + } + + public isLeftButton(): boolean { + const el = this.scroller.nativeElement; + return +this.pruneString(el.style.left) !== 0; + } + + public scrollToLeft(): void { + const scrollValues = this.getValuesToScroll(); + + if (this.isLeftButton()) { + let left = +this.pruneString(scrollValues.el.style.left); + if (left > scrollValues.el.clientWidth) { + left = + left + + (scrollValues.countOfElOutView + scrollValues.restOfCountInView) * scrollValues.imgWidth; + } else { + left = + left + + (scrollValues.countOfElInView + scrollValues.restOfCountInView) * scrollValues.imgWidth - + scrollValues.margin; + } + + left = left > 0 ? 0 : left; + scrollValues.el.style.left = `${left}px`; + } + } + + public removeFile(file: ESImageCarouselRemoveAction): void { + this.remove.emit(file); + } + + public openImageDialog(file: ESImageCarouselFile): void { + this.customDialogService.open(ImageDialogComponent, { + data: { + imageUrl: this.getImage(file) + } + }); + } + + /** + * @internal + * @ignore + */ + public downloadFile(file: ESImageCarouselFile): void { + this.save(file.file, file.name); + } + + private save(file: Blob | string, name?: string): void { + const url = typeof file === 'string' ? file : URL.createObjectURL(file); + const downloadLink = document.createElement('a'); + downloadLink.setAttribute('href', url); + downloadLink.setAttribute('target', '_self'); + downloadLink.setAttribute('download', name ? name : ''); + downloadLink.click(); + } + + private pruneString(val: string): string { + return val.replace(/[а-я]|[a-z]?\s*/g, ''); + } + + private getValuesToScroll() { + const el = this.scroller.nativeElement; + const margin = 16; + const imgWidth = +this.pruneString(this.imgWidth); + const countOfElInView = Math.floor(el.clientWidth / (imgWidth + margin)); + const doubleCountInView = el.clientWidth / (imgWidth + margin); + const doubleCountAllView = el.scrollWidth / (imgWidth + margin); + const countOfElOutView = doubleCountAllView - doubleCountInView; + const restOfCountInView = Math.ceil(doubleCountInView) - doubleCountInView; + + return { + el, + margin, + imgWidth, + countOfElInView, + doubleCountInView, + doubleCountAllView, + countOfElOutView, + restOfCountInView + }; + } +} 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..ca5821ba --- /dev/null +++ b/projects/elonkit/src/ui/image-carousel/image-carousel.module.ts @@ -0,0 +1,15 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatIconModule } from '@angular/material/icon'; + +import { ESImageCarouselComponent } from './image-carousel.component'; +import { ImageDialogModule } from '../custom-dialog/image-dialog/image-dialog.module'; +import { CustomDialogService } from '../custom-dialog'; + +@NgModule({ + declarations: [ESImageCarouselComponent], + imports: [CommonModule, MatIconModule, ImageDialogModule], + exports: [ESImageCarouselComponent], + providers: [CustomDialogService] +}) +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..c0ab101b --- /dev/null +++ b/projects/elonkit/src/ui/image-carousel/image-carousel.types.ts @@ -0,0 +1,15 @@ +export interface ESImageCarouselFile { + id?: number; + deleted?: boolean; + type?: string; + base64?: string; + file?: string; + name: string; + size: number; + content: File; +} + +export interface ESImageCarouselRemoveAction { + file: ESImageCarouselFile; + index: number; +} 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..f4904ffe --- /dev/null +++ b/projects/elonkit/src/ui/image-carousel/public-api.ts @@ -0,0 +1,2 @@ +export * from './image-scroller.module'; +export * from './image-scroller.component'; From b3c7ac3a09469b3e51c509538bb568434bc1176e Mon Sep 17 00:00:00 2001 From: Sergei Klimenko Date: Tue, 21 Jul 2020 18:34:12 +0300 Subject: [PATCH 2/9] feat(image-carousel): add image-carousel component --- jest.config.js | 8 +- .../image-carousel/__specs__/counter.spec.ts | 68 ---- .../image-carousel-story-basic.component.html | 4 +- .../image-carousel-story-basic.component.scss | 3 + .../image-carousel-story-basic.component.ts | 3 +- .../image-carousel-story-basic.module.ts | 4 +- .../custom-dialog/custom-dialog-ref.ts | 17 - .../custom-dialog/custom-dialog.service.ts | 84 ----- .../custom-dialog/custom-dialog.tokens.ts | 3 - .../image-dialog/image-dialog.component.html | 17 - .../image-dialog/image-dialog.component.scss | 60 ---- .../image-dialog/image-dialog.component.ts | 49 --- .../image-dialog/image-dialog.module.ts | 15 - .../ui/image-carousel/custom-dialog/index.ts | 2 - .../image-carousel/fixtures/files.fixture.ts | 34 +- .../image-carousel.component.html | 71 ++-- .../image-carousel.component.locale.ts | 13 + .../image-carousel.component.scss | 102 +++--- .../image-carousel.component.ts | 317 +++++++++++------- .../image-carousel/image-carousel.module.ts | 8 +- .../ui/image-carousel/image-carousel.types.ts | 14 +- .../src/ui/image-carousel/public-api.ts | 12 +- .../elonkit/src/utils/validate-file-type.ts | 18 + projects/elonkit/storybook/main.js | 1 + tsconfig.json | 3 +- 25 files changed, 381 insertions(+), 549 deletions(-) delete mode 100644 projects/elonkit/src/ui/image-carousel/__specs__/counter.spec.ts create mode 100644 projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-basic/image-carousel-story-basic.component.scss delete mode 100644 projects/elonkit/src/ui/image-carousel/custom-dialog/custom-dialog-ref.ts delete mode 100644 projects/elonkit/src/ui/image-carousel/custom-dialog/custom-dialog.service.ts delete mode 100644 projects/elonkit/src/ui/image-carousel/custom-dialog/custom-dialog.tokens.ts delete mode 100644 projects/elonkit/src/ui/image-carousel/custom-dialog/image-dialog/image-dialog.component.html delete mode 100644 projects/elonkit/src/ui/image-carousel/custom-dialog/image-dialog/image-dialog.component.scss delete mode 100644 projects/elonkit/src/ui/image-carousel/custom-dialog/image-dialog/image-dialog.component.ts delete mode 100644 projects/elonkit/src/ui/image-carousel/custom-dialog/image-dialog/image-dialog.module.ts delete mode 100644 projects/elonkit/src/ui/image-carousel/custom-dialog/index.ts create mode 100644 projects/elonkit/src/ui/image-carousel/image-carousel.component.locale.ts create mode 100644 projects/elonkit/src/utils/validate-file-type.ts diff --git a/jest.config.js b/jest.config.js index ac4d43a4..e76b2533 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,3 +1,6 @@ +const { pathsToModuleNameMapper } = require('ts-jest/utils'); +const { compilerOptions } = require('./tsconfig'); + module.exports = { setupFilesAfterEnv: ['./jest.setup.ts'], globals: { @@ -5,5 +8,8 @@ module.exports = { diagnostics: false } }, - testPathIgnorePatterns: ['/dist/'] + testPathIgnorePatterns: ['/dist/'], + roots: [''], + modulePaths: [''], + moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths) }; diff --git a/projects/elonkit/src/ui/image-carousel/__specs__/counter.spec.ts b/projects/elonkit/src/ui/image-carousel/__specs__/counter.spec.ts deleted file mode 100644 index 22de7713..00000000 --- a/projects/elonkit/src/ui/image-carousel/__specs__/counter.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { render } from '@testing-library/angular'; - -import { ESCounterModule, ESCounterComponent } from '..'; - -describe('Counter', () => { - // This is an example of a basic test setup - it('Should render component', async () => { - const { getByText } = await render(ESCounterComponent, { - imports: [ESCounterModule], - excludeComponentDeclaration: true - }); - - // We can use getByText in order to find DOM-node by text - expect(getByText('Counter')).toBeInTheDocument(); - }); - - it('Should render correct heading', async () => { - const { fixture, getByText } = await render(ESCounterComponent, { - // We can pass inputs... - componentProperties: { heading: 'Heading 1' }, - imports: [ESCounterModule], - excludeComponentDeclaration: true - }); - - expect(getByText('Heading 1')).toBeInTheDocument(); - - // ... and change them later on - fixture.componentInstance.heading = 'Heading 2'; - fixture.componentInstance.changeDetector.detectChanges(); - - expect(getByText('Heading 2')).toBeInTheDocument(); - }); - - // This is an example of a actually usefull test - it('Should increase value on click', async () => { - const onIncrease = jest.fn(); - - const { getByText, click } = await render(ESCounterComponent, { - // We can pass outputs - componentProperties: { - increase: { - emit: onIncrease - } as any - }, - imports: [ESCounterModule], - excludeComponentDeclaration: true - }); - - const button = getByText('CLICK'); - - expect(getByText('You clicked 0 times')).toBeInTheDocument(); - expect(getByText('You clicked 0 times')).not.toHaveClass('es-counter__count_active'); - - // We can emulate user interaction - click(button); - - expect(getByText('You clicked 1 times')).toBeInTheDocument(); - expect(getByText('You clicked 1 times')).toHaveClass('es-counter__count_active'); - - click(button); - - 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); - }); -}); 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 index 9a3d4173..98895e3f 100644 --- 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 @@ -1 +1,3 @@ - +
+ +
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..7ebbb5fc --- /dev/null +++ b/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel-story-basic/image-carousel-story-basic.component.scss @@ -0,0 +1,3 @@ +.test { + 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 index 39a205f3..d00e89c4 100644 --- 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 @@ -4,7 +4,8 @@ import { filesFixture } from '../../fixtures/files.fixture'; @Component({ selector: 'es-image-carousel-basic', - templateUrl: './image-carousel-story-basic.component.html' + templateUrl: './image-carousel-story-basic.component.html', + styleUrls: ['./image-carousel-story-basic.component.scss'] }) export class ImageCarouselStoryBasicComponent implements OnInit { 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 index f0d62da0..f3e301aa 100644 --- 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 @@ -3,12 +3,10 @@ import { CommonModule } from '@angular/common'; import { ImageCarouselStoryBasicComponent } from './image-carousel-story-basic.component'; import { ESImageCarouselModule } from '../../image-carousel.module'; -import { HttpClientModule } from '@angular/common/http'; -import { OverlayModule } from '@angular/cdk/overlay'; @NgModule({ declarations: [ImageCarouselStoryBasicComponent], - imports: [CommonModule, OverlayModule, ESImageCarouselModule], + imports: [CommonModule, ESImageCarouselModule], exports: [ImageCarouselStoryBasicComponent] }) export class ImageCarouselStoryBasicModule {} diff --git a/projects/elonkit/src/ui/image-carousel/custom-dialog/custom-dialog-ref.ts b/projects/elonkit/src/ui/image-carousel/custom-dialog/custom-dialog-ref.ts deleted file mode 100644 index 007d4162..00000000 --- a/projects/elonkit/src/ui/image-carousel/custom-dialog/custom-dialog-ref.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { OverlayRef } from '@angular/cdk/overlay'; -import { BehaviorSubject } from 'rxjs'; - -export class CustomDialogRef { - private afterClosed$ = new BehaviorSubject(null); - - constructor(private overlay: OverlayRef) {} - - public close(data?: any): void { - this.overlay.dispose(); - this.afterClosed$.next(data); - } - - public afterClosed() { - return this.afterClosed$.asObservable(); - } -} diff --git a/projects/elonkit/src/ui/image-carousel/custom-dialog/custom-dialog.service.ts b/projects/elonkit/src/ui/image-carousel/custom-dialog/custom-dialog.service.ts deleted file mode 100644 index 2253f996..00000000 --- a/projects/elonkit/src/ui/image-carousel/custom-dialog/custom-dialog.service.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Injectable, Injector, ComponentRef } from '@angular/core'; -import { Overlay, OverlayRef, OverlayConfig } from '@angular/cdk/overlay'; -import { ComponentPortal, PortalInjector, ComponentType } from '@angular/cdk/portal'; - -import { CustomDialogRef } from './custom-dialog-ref'; -import { CUSTOM_DIALOG_DATA } from './custom-dialog.tokens'; - -interface CustomDialogConfig { - panelClass?: string; - hasBackdrop?: boolean; - backdropClass?: string; - data?: any; -} - -const DEFAULT_CONFIG: CustomDialogConfig = { - hasBackdrop: true -}; - -@Injectable() -export class CustomDialogService { - constructor(private injector: Injector, private overlay: Overlay) {} - - public open(component: ComponentType, config: CustomDialogConfig = {}) { - const dialogConfig = { ...DEFAULT_CONFIG, ...config }; - const overlayRef = this.createOverlay(dialogConfig); - const dialogRef = new CustomDialogRef(overlayRef); - const overlayComponent = this.attachDialogContainer( - component, - overlayRef, - dialogConfig, - dialogRef - ); - - overlayRef.backdropClick().subscribe(_ => dialogRef.close()); - - return dialogRef; - } - - private createOverlay(config: CustomDialogConfig) { - const overlayConfig = this.getOverlayConfig(config); - return this.overlay.create(overlayConfig); - } - - private attachDialogContainer( - component: ComponentType, - overlayRef: OverlayRef, - config: CustomDialogConfig, - dialogRef: CustomDialogRef - ) { - const injector = this.createInjector(config, dialogRef); - - const containerPortal = new ComponentPortal(component, null, injector); - const containerRef: ComponentRef = overlayRef.attach(containerPortal); - - return containerRef.instance; - } - - private createInjector(config: CustomDialogConfig, dialogRef: CustomDialogRef): PortalInjector { - const injectionTokens = new WeakMap(); - - injectionTokens.set(CustomDialogRef, dialogRef); - injectionTokens.set(CUSTOM_DIALOG_DATA, config.data); - - return new PortalInjector(this.injector, injectionTokens); - } - - private getOverlayConfig(config: CustomDialogConfig): OverlayConfig { - const positionStrategy = this.overlay - .position() - .global() - .centerHorizontally() - .centerVertically(); - - const overlayConfig = new OverlayConfig({ - hasBackdrop: config.hasBackdrop, - backdropClass: config.backdropClass, - panelClass: config.panelClass, - scrollStrategy: this.overlay.scrollStrategies.block(), - positionStrategy - }); - - return overlayConfig; - } -} diff --git a/projects/elonkit/src/ui/image-carousel/custom-dialog/custom-dialog.tokens.ts b/projects/elonkit/src/ui/image-carousel/custom-dialog/custom-dialog.tokens.ts deleted file mode 100644 index e1907e79..00000000 --- a/projects/elonkit/src/ui/image-carousel/custom-dialog/custom-dialog.tokens.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { InjectionToken } from '@angular/core'; - -export const CUSTOM_DIALOG_DATA = new InjectionToken('CUSTOM_DIALOG_DATA'); diff --git a/projects/elonkit/src/ui/image-carousel/custom-dialog/image-dialog/image-dialog.component.html b/projects/elonkit/src/ui/image-carousel/custom-dialog/image-dialog/image-dialog.component.html deleted file mode 100644 index e9af4916..00000000 --- a/projects/elonkit/src/ui/image-carousel/custom-dialog/image-dialog/image-dialog.component.html +++ /dev/null @@ -1,17 +0,0 @@ - -
-
-
-
-
- delete - Delete -
-
- diff --git a/projects/elonkit/src/ui/image-carousel/custom-dialog/image-dialog/image-dialog.component.scss b/projects/elonkit/src/ui/image-carousel/custom-dialog/image-dialog/image-dialog.component.scss deleted file mode 100644 index bc5c7c91..00000000 --- a/projects/elonkit/src/ui/image-carousel/custom-dialog/image-dialog/image-dialog.component.scss +++ /dev/null @@ -1,60 +0,0 @@ -:host { - align-items: center; - display: flex; - justify-content: center; - left: 0; - top: 0; -} - -.image-modal { - display: block; - height: 60%; - justify-content: center; - position: absolute; - width: 30%; - - &__image { - background-position: center top; - background-repeat: no-repeat; - background-size: contain; - height: 100%; - width: 100%; - } - - &__loading { - z-index: 1; - } - - &__close { - background: rgba(0, 0, 0, 0.54); - border-radius: 4px; - color: #fff; - height: 48px; - position: absolute; - right: 16px; - top: 16px; - width: 48px; - } -} - -.actions { - bottom: 48px; - margin: auto; - position: absolute; - z-index: 1; - - &__action { - align-items: center; - background: rgba(0, 0, 0, 0.54); - border-radius: 4px; - color: #fff; - cursor: pointer; - display: flex; - font-family: Roboto; - padding: 8px 16px; - } - - &__delete-icon { - margin-right: 8px; - } -} diff --git a/projects/elonkit/src/ui/image-carousel/custom-dialog/image-dialog/image-dialog.component.ts b/projects/elonkit/src/ui/image-carousel/custom-dialog/image-dialog/image-dialog.component.ts deleted file mode 100644 index d8604dcd..00000000 --- a/projects/elonkit/src/ui/image-carousel/custom-dialog/image-dialog/image-dialog.component.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Component, Inject, OnInit } from '@angular/core'; -import { CustomDialogRef } from '../custom-dialog-ref'; -import { CUSTOM_DIALOG_DATA } from '../custom-dialog.tokens'; -import { Observable } from 'rxjs'; -import { DomSanitizer } from '@angular/platform-browser'; - -@Component({ - selector: 'es-image-dialog', - templateUrl: './image-dialog.component.html', - styleUrls: ['./image-dialog.component.scss'] -}) -export class ImageDialogComponent implements OnInit { - public imageUrl: string; - public isLoading = false; - public deleteImageFn; - - constructor( - private dialogRef: CustomDialogRef, - @Inject(CUSTOM_DIALOG_DATA) public data: any, - private sanitizer: DomSanitizer - ) {} - - public ngOnInit(): void { - this.imageUrl = this.data.imageUrl; - this.deleteImageFn = this.data.deleteImageFn; - } - - public closeImageDialog() { - this.dialogRef.close({ imageUrl: this.imageUrl }); - } - - public deleteImage() { - const obs = this.deleteImageFn(); - this.isLoading = true; - obs.pipe().subscribe( - () => { - this.isLoading = false; - this.dialogRef.close({ imageUrl: null }); - }, - () => { - this.isLoading = false; - } - ); - } - - public getSanitizedImage() { - return this.sanitizer.bypassSecurityTrustStyle(`url('${this.imageUrl}')`); - } -} diff --git a/projects/elonkit/src/ui/image-carousel/custom-dialog/image-dialog/image-dialog.module.ts b/projects/elonkit/src/ui/image-carousel/custom-dialog/image-dialog/image-dialog.module.ts deleted file mode 100644 index d3cd7210..00000000 --- a/projects/elonkit/src/ui/image-carousel/custom-dialog/image-dialog/image-dialog.module.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; - -import { ImageDialogComponent } from './image-dialog.component'; - -@NgModule({ - declarations: [ImageDialogComponent], - imports: [CommonModule, MatProgressSpinnerModule, MatButtonModule, MatIconModule], - exports: [ImageDialogComponent], - entryComponents: [ImageDialogComponent] -}) -export class ImageDialogModule {} diff --git a/projects/elonkit/src/ui/image-carousel/custom-dialog/index.ts b/projects/elonkit/src/ui/image-carousel/custom-dialog/index.ts deleted file mode 100644 index 3c2b2d21..00000000 --- a/projects/elonkit/src/ui/image-carousel/custom-dialog/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { CustomDialogService } from './custom-dialog.service'; -export { CUSTOM_DIALOG_DATA } from './custom-dialog.tokens'; diff --git a/projects/elonkit/src/ui/image-carousel/fixtures/files.fixture.ts b/projects/elonkit/src/ui/image-carousel/fixtures/files.fixture.ts index a825cacc..77e3ac69 100644 --- a/projects/elonkit/src/ui/image-carousel/fixtures/files.fixture.ts +++ b/projects/elonkit/src/ui/image-carousel/fixtures/files.fixture.ts @@ -4,7 +4,7 @@ export const filesFixture: ESImageCarouselFile[] = [ { id: 1, type: 'image/jpg', - file: 'https://dummyimage.com/400x400/405ed6/fff.jpg&text=ES', + file: 'https://dummyimage.com/400x400/405ed6/fff.jpg&text=ES1', name: 'FileName1.jpg', size: 45678, content: null @@ -12,15 +12,15 @@ export const filesFixture: ESImageCarouselFile[] = [ { id: 2, type: 'image/jpg', - file: 'https://dummyimage.com/400x400/228a0f/fff.jpg&text=ES', + file: 'https://dummyimage.com/400x400/28B463/fff.jpg&text=ES2', name: 'FileName2.jpg', size: 456789, content: null }, { id: 3, - type: 'application/pdf', - file: 'https://dummyimage.com/400x400/d6761c/fff.jpg&text=ES', + type: 'image/jpg', + file: 'https://dummyimage.com/400x400/d6761c/fff.jpg&text=ES3', name: 'FileName3.pdf', size: 4567, content: null @@ -28,7 +28,31 @@ export const filesFixture: ESImageCarouselFile[] = [ { id: 4, type: 'image/jpg', - file: 'https://dummyimage.com/900x400/2dbdb8/fff.jpg&text=ES', + 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 index 82b74c2c..40c5f36c 100644 --- a/projects/elonkit/src/ui/image-carousel/image-carousel.component.html +++ b/projects/elonkit/src/ui/image-carousel/image-carousel.component.html @@ -1,49 +1,56 @@ -
-
+ diff --git a/projects/elonkit/src/ui/image-carousel/image-carousel.component.locale.ts b/projects/elonkit/src/ui/image-carousel/image-carousel.component.locale.ts new file mode 100644 index 00000000..4f0879f6 --- /dev/null +++ b/projects/elonkit/src/ui/image-carousel/image-carousel.component.locale.ts @@ -0,0 +1,13 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ providedIn: 'root' }) +export class ESImageCarouselLocale { + labelView = 'View'; + labelRemove = 'Remove'; +} + +@Injectable() +export class ESImageCarouselLocaleRU extends ESImageCarouselLocale { + labelView = 'Смотреть'; + labelRemove = 'Удалить'; +} diff --git a/projects/elonkit/src/ui/image-carousel/image-carousel.component.scss b/projects/elonkit/src/ui/image-carousel/image-carousel.component.scss index 00e028b3..6a962556 100644 --- a/projects/elonkit/src/ui/image-carousel/image-carousel.component.scss +++ b/projects/elonkit/src/ui/image-carousel/image-carousel.component.scss @@ -1,93 +1,69 @@ -.image-scroller { +.es-image-carousel { + display: inline-block; overflow: hidden; position: relative; + width: 100%; &__items { - left: 0; - position: relative; - transition: all 1s; - white-space: nowrap; + display: grid; + grid-template-columns: repeat(auto-fit, 160px); + transition: transform 1s ease-in-out; } &__item { + align-items: center; background-position: center; background-size: cover; border-radius: 6px; - cursor: pointer; - display: inline-block; - max-width: 100%; - min-width: 0; + display: flex; + justify-content: center; position: relative; - &:not(:last-child) { - margin-right: 16px; + &:hover &-view, + &:hover &-remove { + visibility: visible; } - &-view { - align-items: center; - background-color: rgba(0, 0, 0, 0.54); - border-radius: 50%; - cursor: pointer; - display: flex; - height: 48px; - justify-content: center; - left: calc(50% - 24px); - position: absolute; - top: calc(50% - 24px); + &-view.mat-icon-button { + background: rgba(0, 0, 0, 0.54); + border-radius: 20px; + color: #fff; visibility: hidden; - width: 48px; - - .mat-icon { - color: #fff; - } } - &-icon { - align-items: center; - background-color: rgba(0, 0, 0, 0.38); - border-radius: 6px; - display: flex; - height: 32px; - justify-content: center; + &-remove.mat-icon-button { + background: rgba(0, 0, 0, 0.54); + border-radius: 4px; + color: #fff; + height: 24px; + line-height: 12px; position: absolute; right: 8px; top: 8px; visibility: hidden; - width: 32px; - - .mat-icon { - color: #fff; - } - } - - &:hover &-icon, - &:hover &-view { - visibility: visible !important; + width: 24px; } } - &__arrow { - align-items: center; - background-color: rgba(0, 0, 0, 0.54); - border-radius: 6px; - cursor: pointer; - display: flex; - height: 40px; - justify-content: center; + &__arrow.mat-icon-button { + background: rgba(0, 0, 0, 0.54); + border-radius: 4px; + color: #fff; position: absolute; - top: calc(50% - 40px / 2); - width: 40px; + visibility: hidden; + } - &_left { - left: 16px; - } + &__arrow_right.mat-icon-button { + right: 16px; + top: calc(50% - 40px / 2); + } - &_right { - right: 16px; - } + &__arrow_left.mat-icon-button { + left: 16px; + top: calc(50% - 40px / 2); + } - .mat-icon { - color: #fff; - } + &__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 index 17602748..0864df4f 100644 --- a/projects/elonkit/src/ui/image-carousel/image-carousel.component.ts +++ b/projects/elonkit/src/ui/image-carousel/image-carousel.component.ts @@ -5,16 +5,26 @@ import { ElementRef, Output, EventEmitter, - AfterViewChecked, - ChangeDetectorRef, ChangeDetectionStrategy, - ViewEncapsulation + ViewEncapsulation, + OnInit, + InjectionToken, + Optional, + Inject } from '@angular/core'; -import { DomSanitizer, SafeStyle } from '@angular/platform-browser'; +import { coerceNumberProperty, coerceBooleanProperty } from '@angular/cdk/coercion'; -import { CustomDialogService } from './custom-dialog'; -import { ImageDialogComponent } from './custom-dialog/image-dialog/image-dialog.component'; -import { ESImageCarouselFile, ESImageCarouselRemoveAction } from './image-carousel.types'; +import { + ESImageCarouselFile, + ESImageCarouselAction, + ESImageCarouselOptions +} from './image-carousel.types'; +import { ESImageCarouselLocale } from './image-carousel.component.locale'; +import { validateFileType } from '~utils/validate-file-type'; + +export const ES_IMAGE_CAROUSEL_DEFAULT_OPTIONS = new InjectionToken( + 'ES_IMAGE_CAROUSEL_DEFAULT_OPTIONS' +); @Component({ selector: 'es-image-carousel', @@ -23,156 +33,227 @@ import { ESImageCarouselFile, ESImageCarouselRemoveAction } from './image-carous changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None }) -export class ESImageCarouselComponent implements AfterViewChecked { - public imageTypes = ['image/png', 'image/jpg', 'image/jpeg', 'image']; +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 files: ESImageCarouselFile[]; + 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 imgHeight = '160px'; + 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 imgWidth = '160px'; + 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 canRemove = true; + 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 canDownload = false; + 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; + + /** + * Object with `ESImageCarouselAction` type is emitted. + */ @Output() - public remove: EventEmitter = new EventEmitter(); + public view: EventEmitter = new EventEmitter(); - @ViewChild('scroller', { static: true }) - public scroller: ElementRef; + /** + * 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 + */ constructor( - private customDialogService: CustomDialogService, - private sanitizer: DomSanitizer, - private cdRef: ChangeDetectorRef - ) {} + @Optional() + @Inject(ES_IMAGE_CAROUSEL_DEFAULT_OPTIONS) + private defaultOptions: ESImageCarouselOptions, + public locale: ESImageCarouselLocale + ) { + 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; + } - public ngAfterViewChecked(): void { - this.cdRef.markForCheck(); + /** + * @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; } - public getSanitizedImage(file: ESImageCarouselFile): SafeStyle { - return this.sanitizer.bypassSecurityTrustStyle(`url('${this.getImage(file)}')`); + /** + * @internal + * @ignore + */ + public slideRight(): void { + this.slideCount++; + this.carouselPosition = this.carouselPosition - 176; } - public scrollToRight(): void { - const scrollValues = this.getValuesToScroll(); - if (this.isRightButton()) { - let left = +this.pruneString(scrollValues.el.style.left); - if (scrollValues.countOfElOutView > scrollValues.el.clientWidth) { - left = - left - - (scrollValues.countOfElOutView + scrollValues.restOfCountInView) * scrollValues.imgWidth; - } else { - left = - left - - (scrollValues.countOfElInView + scrollValues.restOfCountInView) * scrollValues.imgWidth + - scrollValues.margin; - } - - left = - Math.abs(left) + scrollValues.el.clientWidth > scrollValues.el.scrollWidth - ? -(scrollValues.el.scrollWidth - scrollValues.el.clientWidth) - : left; - - scrollValues.el.style.left = `${left}px`; - } + /** + * @internal + * @ignore + */ + public slideLeft(): void { + this.slideCount--; + this.carouselPosition = this.carouselPosition + 176; } - public isRightButton(): boolean { - const el = this.scroller.nativeElement; - const left = Math.abs(+this.pruneString(el.style.left)) + el.offsetWidth; - return left !== el.scrollWidth; + /** + * @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; } - public isLeftButton(): boolean { - const el = this.scroller.nativeElement; - return +this.pruneString(el.style.left) !== 0; + /** + * @internal + * @ignore + */ + public get getTranslateX(): string { + return `translateX(${this.carouselPosition}px)`; } - public scrollToLeft(): void { - const scrollValues = this.getValuesToScroll(); - - if (this.isLeftButton()) { - let left = +this.pruneString(scrollValues.el.style.left); - if (left > scrollValues.el.clientWidth) { - left = - left + - (scrollValues.countOfElOutView + scrollValues.restOfCountInView) * scrollValues.imgWidth; - } else { - left = - left + - (scrollValues.countOfElInView + scrollValues.restOfCountInView) * scrollValues.imgWidth - - scrollValues.margin; - } - - left = left > 0 ? 0 : left; - scrollValues.el.style.left = `${left}px`; - } + /** + * @internal + * @ignore + */ + public get canScrollRight(): boolean { + return this.slideCount < this.maxSlideCount; } - public removeFile(file: ESImageCarouselRemoveAction): void { - this.remove.emit(file); + /** + * @internal + * @ignore + */ + public get canScrollLeft(): boolean { + return Math.abs(this.carouselPosition) >= this.imageWidth + this.gap; } - public openImageDialog(file: ESImageCarouselFile): void { - this.customDialogService.open(ImageDialogComponent, { - data: { - imageUrl: this.getImage(file) - } - }); + /** + * @internal + * @ignore + */ + public viewFile(file: ESImageCarouselAction): void { + this.view.emit(file); } /** * @internal * @ignore */ - public downloadFile(file: ESImageCarouselFile): void { - this.save(file.file, file.name); - } - - private save(file: Blob | string, name?: string): void { - const url = typeof file === 'string' ? file : URL.createObjectURL(file); - const downloadLink = document.createElement('a'); - downloadLink.setAttribute('href', url); - downloadLink.setAttribute('target', '_self'); - downloadLink.setAttribute('download', name ? name : ''); - downloadLink.click(); - } - - private pruneString(val: string): string { - return val.replace(/[а-я]|[a-z]?\s*/g, ''); - } - - private getValuesToScroll() { - const el = this.scroller.nativeElement; - const margin = 16; - const imgWidth = +this.pruneString(this.imgWidth); - const countOfElInView = Math.floor(el.clientWidth / (imgWidth + margin)); - const doubleCountInView = el.clientWidth / (imgWidth + margin); - const doubleCountAllView = el.scrollWidth / (imgWidth + margin); - const countOfElOutView = doubleCountAllView - doubleCountInView; - const restOfCountInView = Math.ceil(doubleCountInView) - doubleCountInView; - - return { - el, - margin, - imgWidth, - countOfElInView, - doubleCountInView, - doubleCountAllView, - countOfElOutView, - restOfCountInView - }; + public removeFile(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 index ca5821ba..bb87ed03 100644 --- a/projects/elonkit/src/ui/image-carousel/image-carousel.module.ts +++ b/projects/elonkit/src/ui/image-carousel/image-carousel.module.ts @@ -1,15 +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'; -import { ImageDialogModule } from '../custom-dialog/image-dialog/image-dialog.module'; -import { CustomDialogService } from '../custom-dialog'; @NgModule({ declarations: [ESImageCarouselComponent], - imports: [CommonModule, MatIconModule, ImageDialogModule], - exports: [ESImageCarouselComponent], - providers: [CustomDialogService] + 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 index c0ab101b..54b708b6 100644 --- a/projects/elonkit/src/ui/image-carousel/image-carousel.types.ts +++ b/projects/elonkit/src/ui/image-carousel/image-carousel.types.ts @@ -6,10 +6,20 @@ export interface ESImageCarouselFile { file?: string; name: string; size: number; - content: File; + content: File | string; } -export interface ESImageCarouselRemoveAction { +export interface ESImageCarouselAction { file: ESImageCarouselFile; index: number; } + +export interface ESImageCarouselOptions { + imageTypes?: string; + imageHeight?: number; + imageWidth?: number; + gap?: number; + canRemove?: boolean; + canView?: boolean; + viewSvgIcon?: string; +} diff --git a/projects/elonkit/src/ui/image-carousel/public-api.ts b/projects/elonkit/src/ui/image-carousel/public-api.ts index f4904ffe..a2119766 100644 --- a/projects/elonkit/src/ui/image-carousel/public-api.ts +++ b/projects/elonkit/src/ui/image-carousel/public-api.ts @@ -1,2 +1,10 @@ -export * from './image-scroller.module'; -export * from './image-scroller.component'; +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/utils/validate-file-type.ts b/projects/elonkit/src/utils/validate-file-type.ts new file mode 100644 index 00000000..464c5402 --- /dev/null +++ b/projects/elonkit/src/utils/validate-file-type.ts @@ -0,0 +1,18 @@ +export const validateFileType = (file: any, types: string): boolean => { + const typesArr = types.split(',').map(v => v.trim()); + return typesArr.includes('*') || typesMatch(typesArr, file); +}; + +const typesMatch = (types: string[], file: File): boolean => + types.some( + type => + type === file.type || + typeMatchesFileType(type, file.type) || + typeMatchesFilenameExtension(type, file.name) + ); + +const typeMatchesFilenameExtension = (type: string, fileName: string): boolean => + type.charAt(0) === '.' && fileName.toLowerCase().endsWith(type); + +const typeMatchesFileType = (type: string, fileType: string): boolean => + type.endsWith('/*') && fileType.startsWith(type.replace(/\/.*$/, '')); diff --git a/projects/elonkit/storybook/main.js b/projects/elonkit/storybook/main.js index ce986ac3..365d4a6d 100644 --- a/projects/elonkit/storybook/main.js +++ b/projects/elonkit/storybook/main.js @@ -19,6 +19,7 @@ module.exports = { ], webpackFinal: async config => { config.resolve.alias['~storybook'] = path.resolve(__dirname); + config.resolve.alias['~utils'] = path.resolve(__dirname, '../src/utils'); // https://github.com/storybookjs/storybook/issues/714 config.plugins.push( diff --git a/tsconfig.json b/tsconfig.json index 17ed2818..21a41c81 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,8 @@ "paths": { "elonkit": ["dist/elonkit"], "elonkit/*": ["dist/elonkit/*"], - "~storybook/*": ["projects/elonkit/storybook/*"] + "~storybook/*": ["projects/elonkit/storybook/*"], + "~utils/*": ["projects/elonkit/src/utils/*"] }, "types": ["jest"], "jsx": "react" From b4a43ff06c52941067738b5fb806c17b1afa5b75 Mon Sep 17 00:00:00 2001 From: Sergei Klimenko Date: Tue, 21 Jul 2020 19:32:45 +0300 Subject: [PATCH 3/9] refactor(image-carousel): refactor stories template --- projects/elonkit/src/public-api.ts | 1 + .../image-carousel-story-basic.component.html | 12 +++- .../image-carousel-story-basic.component.scss | 2 +- .../image-carousel-story-basic.component.ts | 22 ++++-- .../__stories__/image-carousel.stories.mdx | 71 +++++++++++++++++-- .../image-carousel.component.html | 9 ++- .../image-carousel.component.locale.ts | 4 ++ .../image-carousel.component.scss | 1 - .../image-carousel.component.ts | 30 ++++++-- 9 files changed, 131 insertions(+), 21 deletions(-) diff --git a/projects/elonkit/src/public-api.ts b/projects/elonkit/src/public-api.ts index 945ee2d0..85f6e58c 100644 --- a/projects/elonkit/src/public-api.ts +++ b/projects/elonkit/src/public-api.ts @@ -4,3 +4,4 @@ export * from './ui/inline-form-field'; export * from './ui/paginator'; export * from './ui/timepicker'; export * from './ui/action-heading'; +export * from './ui/image-carousel'; 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 index 98895e3f..1e17a28c 100644 --- 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 @@ -1,3 +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 index 7ebbb5fc..5fa76b4e 100644 --- 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 @@ -1,3 +1,3 @@ .test { - width: 600px; + 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 index d00e89c4..f8215986 100644 --- 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 @@ -1,14 +1,26 @@ -import { Component, OnInit } from '@angular/core'; +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'] + styleUrls: ['./image-carousel-story-basic.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush }) -export class ImageCarouselStoryBasicComponent implements OnInit { - public files: ESImageCarouselFile[] = filesFixture; +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; - ngOnInit() {} + public files: ESImageCarouselFile[] = filesFixture; } 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 index e8ace804..fa2df78a 100644 --- a/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel.stories.mdx +++ b/projects/elonkit/src/ui/image-carousel/__stories__/image-carousel.stories.mdx @@ -3,7 +3,7 @@ import { Preview } from '~storybook/components'; import { withA11y } from '@storybook/addon-a11y'; import { action } from '@storybook/addon-actions'; -import { withKnobs, text } from '@storybook/addon-knobs'; +import { withKnobs, text, number, boolean } from '@storybook/addon-knobs'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; @@ -17,18 +17,28 @@ import { -# Inline Form Field +# Image Carousel -This component allows inline switching between display value and input. +This component displays images and allows to slide them. ## Demos - + {{ component: ImageCarouselStoryBasicComponent, moduleMetadata: { imports: [BrowserAnimationsModule, ImageCarouselStoryBasicModule] + }, + props: { + imageHeight: number('imageHeight', 160), + imageWidth: number('imageWidth', 160), + gap: number('gap', 16), + canView: boolean('canView', true), + canRemove: boolean('canRemove', true), + imageTypes: text('imageTypes', 'image/png,image/jpg,image/jpeg'), + onView: action('onView'), + onRemove: action('onRemove') } }} @@ -38,19 +48,66 @@ This component allows inline switching between display value and input. +## 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; +} +``` + ## Constants +```ts +class ESImageCarouselLocale { + labelView = 'View'; + labelRemove = 'Remove'; + labelSlideRight = 'Slide right'; + labelSlideLeft = 'Slide left'; +} +``` + Injection token that can be used to configure the default options for all components within an app. ```ts -import { ES_INLINE_FORM_FIELD_DEFAULT_OPTIONS } from '@elonsoft/elonkit/ui/inline-form-field'; +import { ES_IMAGE_CAROUSEL_DEFAULT_OPTIONS } from '@elonsoft/elonkit/ui/image-carousel'; @NgModule({ providers: [ { - provide: ES_INLINE_FORM_FIELD_DEFAULT_OPTIONS, + provide: ES_IMAGE_CAROUSEL_DEFAULT_OPTIONS, useValue: { - typography: 'mat-body-1' + imageHeight: 200, + imageWidth: 200, + gap: 20 } } ] diff --git a/projects/elonkit/src/ui/image-carousel/image-carousel.component.html b/projects/elonkit/src/ui/image-carousel/image-carousel.component.html index 40c5f36c..7f32eb4f 100644 --- a/projects/elonkit/src/ui/image-carousel/image-carousel.component.html +++ b/projects/elonkit/src/ui/image-carousel/image-carousel.component.html @@ -1,7 +1,12 @@