From 46614a7c246454fb9825233c802ded9458dc965b Mon Sep 17 00:00:00 2001 From: Subhajit Ghosh <99127578+subhajit20@users.noreply.github.com> Date: Sat, 7 Oct 2023 01:42:09 +0530 Subject: [PATCH] Create carousel component for testimonials (#2394) * Create carousel component for testimonials * Update changelog --------- Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com> --- CHANGELOG.md | 1 + .../src/app/pages/landing/landing-page.html | 45 +++--- .../app/pages/landing/landing-page.module.ts | 2 + .../lib/carousel/carousel-item.directive.ts | 16 ++ .../src/lib/carousel/carousel.component.html | 34 ++++ .../src/lib/carousel/carousel.component.scss | 34 ++++ .../ui/src/lib/carousel/carousel.component.ts | 147 ++++++++++++++++++ libs/ui/src/lib/carousel/carousel.module.ts | 14 ++ libs/ui/src/lib/carousel/index.ts | 1 + 9 files changed, 274 insertions(+), 20 deletions(-) create mode 100644 libs/ui/src/lib/carousel/carousel-item.directive.ts create mode 100644 libs/ui/src/lib/carousel/carousel.component.html create mode 100644 libs/ui/src/lib/carousel/carousel.component.scss create mode 100644 libs/ui/src/lib/carousel/carousel.component.ts create mode 100644 libs/ui/src/lib/carousel/carousel.module.ts create mode 100644 libs/ui/src/lib/carousel/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d03655ff8..40c7e843e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added support for notes in the activities import - Added the application version to the endpoint `GET api/v1/admin` +- Introduced a carousel component for the testimonial section on the landing page ### Fixed diff --git a/apps/client/src/app/pages/landing/landing-page.html b/apps/client/src/app/pages/landing/landing-page.html index 5795a4fb3..96d5c4f7a 100644 --- a/apps/client/src/app/pages/landing/landing-page.html +++ b/apps/client/src/app/pages/landing/landing-page.html @@ -320,31 +320,36 @@
-

+

What our users are saying

-
-
-
- -
-
-
{{ testimonial.quote }}
-
- — - {{ testimonial.author }} - {{ testimonial.author }}, {{ - testimonial.country }} +
+ +
+
+ +
+
{{ testimonial.quote }}
+
+ — + {{ testimonial.author }} + {{ testimonial.author }}, + {{ testimonial.country }} +
+
-
+
diff --git a/apps/client/src/app/pages/landing/landing-page.module.ts b/apps/client/src/app/pages/landing/landing-page.module.ts index 9a3f02cce..4357f52fc 100644 --- a/apps/client/src/app/pages/landing/landing-page.module.ts +++ b/apps/client/src/app/pages/landing/landing-page.module.ts @@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { RouterModule } from '@angular/router'; import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module'; +import { GfCarouselModule } from '@ghostfolio/ui/carousel'; import { GfLogoModule } from '@ghostfolio/ui/logo'; import { GfValueModule } from '@ghostfolio/ui/value'; @@ -14,6 +15,7 @@ import { LandingPageComponent } from './landing-page.component'; declarations: [LandingPageComponent], imports: [ CommonModule, + GfCarouselModule, GfLogoModule, GfValueModule, GfWorldMapChartModule, diff --git a/libs/ui/src/lib/carousel/carousel-item.directive.ts b/libs/ui/src/lib/carousel/carousel-item.directive.ts new file mode 100644 index 000000000..95fefe5cc --- /dev/null +++ b/libs/ui/src/lib/carousel/carousel-item.directive.ts @@ -0,0 +1,16 @@ +import { FocusableOption } from '@angular/cdk/a11y'; +import { Directive, ElementRef, HostBinding } from '@angular/core'; + +@Directive({ + selector: '[gf-carousel-item]' +}) +export class CarouselItem implements FocusableOption { + @HostBinding('attr.role') readonly role = 'listitem'; + @HostBinding('tabindex') tabindex = '-1'; + + public constructor(readonly element: ElementRef) {} + + public focus(): void { + this.element.nativeElement.focus({ preventScroll: true }); + } +} diff --git a/libs/ui/src/lib/carousel/carousel.component.html b/libs/ui/src/lib/carousel/carousel.component.html new file mode 100644 index 000000000..59966b8a6 --- /dev/null +++ b/libs/ui/src/lib/carousel/carousel.component.html @@ -0,0 +1,34 @@ + + +
+ +
+ + diff --git a/libs/ui/src/lib/carousel/carousel.component.scss b/libs/ui/src/lib/carousel/carousel.component.scss new file mode 100644 index 000000000..38da7c100 --- /dev/null +++ b/libs/ui/src/lib/carousel/carousel.component.scss @@ -0,0 +1,34 @@ +:host { + display: block; + position: relative; + + ::ng-deep { + [gf-carousel-item] { + flex-shrink: 0; + width: 100%; + } + } + + button { + top: 50%; + transform: translateY(-50%); + + &.carousel-nav-prev { + left: -50px; + } + + &.carousel-nav-next { + right: -50px; + } + } + + .carousel-content { + flex-direction: row; + outline: none; + transition: transform 0.5s ease-in-out; + + .animations-disabled & { + transition: none; + } + } +} diff --git a/libs/ui/src/lib/carousel/carousel.component.ts b/libs/ui/src/lib/carousel/carousel.component.ts new file mode 100644 index 000000000..a0eb0f8a1 --- /dev/null +++ b/libs/ui/src/lib/carousel/carousel.component.ts @@ -0,0 +1,147 @@ +import { FocusKeyManager } from '@angular/cdk/a11y'; +import { LEFT_ARROW, RIGHT_ARROW, TAB } from '@angular/cdk/keycodes'; +import { + AfterContentInit, + ChangeDetectionStrategy, + Component, + ContentChildren, + ElementRef, + HostBinding, + Inject, + Input, + Optional, + QueryList, + ViewChild +} from '@angular/core'; +import { ANIMATION_MODULE_TYPE } from '@angular/platform-browser/animations'; + +import { CarouselItem } from './carousel-item.directive'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'gf-carousel', + styleUrls: ['./carousel.component.scss'], + templateUrl: './carousel.component.html' +}) +export class CarouselComponent implements AfterContentInit { + @ContentChildren(CarouselItem) public items!: QueryList; + + @HostBinding('class.animations-disabled') + public readonly animationsDisabled: boolean; + + @Input('aria-label') public ariaLabel: string | undefined; + + @ViewChild('list') public list!: ElementRef; + + public showPrevArrow = false; + public showNextArrow = true; + + private index = 0; + private keyManager!: FocusKeyManager; + private position = 0; + + public constructor( + @Optional() @Inject(ANIMATION_MODULE_TYPE) animationsModule?: string + ) { + this.animationsDisabled = animationsModule === 'NoopAnimations'; + } + + public ngAfterContentInit() { + this.keyManager = new FocusKeyManager(this.items); + } + + public next() { + for (let i = this.index; i < this.items.length; i++) { + if (this.isOutOfView(i)) { + this.index = i; + this.scrollToActiveItem(); + break; + } + } + } + + public onKeydown({ keyCode }: KeyboardEvent) { + const manager = this.keyManager; + const previousActiveIndex = manager.activeItemIndex; + + if (keyCode === LEFT_ARROW) { + manager.setPreviousItemActive(); + } else if (keyCode === RIGHT_ARROW) { + manager.setNextItemActive(); + } else if (keyCode === TAB && !manager.activeItem) { + manager.setFirstItemActive(); + } + + if ( + manager.activeItemIndex != null && + manager.activeItemIndex !== previousActiveIndex + ) { + this.index = manager.activeItemIndex; + this.updateItemTabIndices(); + + if (this.isOutOfView(this.index)) { + this.scrollToActiveItem(); + } + } + } + + public previous() { + for (let i = this.index; i > -1; i--) { + if (this.isOutOfView(i)) { + this.index = i; + this.scrollToActiveItem(); + break; + } + } + } + + private isOutOfView(index: number, side?: 'start' | 'end') { + const { offsetWidth, offsetLeft } = + this.items.toArray()[index].element.nativeElement; + + if ((!side || side === 'start') && offsetLeft - this.position < 0) { + return true; + } + + return ( + (!side || side === 'end') && + offsetWidth + offsetLeft - this.position > + this.list.nativeElement.clientWidth + ); + } + + private scrollToActiveItem() { + if (!this.isOutOfView(this.index)) { + return; + } + + const itemsArray = this.items.toArray(); + let targetItemIndex = this.index; + + if (this.index > 0 && !this.isOutOfView(this.index - 1)) { + targetItemIndex = + itemsArray.findIndex((_, i) => !this.isOutOfView(i)) + 1; + } + + this.position = + itemsArray[targetItemIndex].element.nativeElement.offsetLeft; + this.list.nativeElement.style.transform = `translateX(-${this.position}px)`; + this.showPrevArrow = this.index > 0; + this.showNextArrow = false; + + for (let i = itemsArray.length - 1; i > -1; i--) { + if (this.isOutOfView(i, 'end')) { + this.showNextArrow = true; + break; + } + } + } + + private updateItemTabIndices() { + this.items.forEach((item: CarouselItem) => { + if (this.keyManager != null) { + item.tabindex = item === this.keyManager.activeItem ? '0' : '-1'; + } + }); + } +} diff --git a/libs/ui/src/lib/carousel/carousel.module.ts b/libs/ui/src/lib/carousel/carousel.module.ts new file mode 100644 index 000000000..4e43f23b0 --- /dev/null +++ b/libs/ui/src/lib/carousel/carousel.module.ts @@ -0,0 +1,14 @@ +import { CommonModule } from '@angular/common'; +import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; + +import { CarouselItem } from './carousel-item.directive'; +import { CarouselComponent } from './carousel.component'; + +@NgModule({ + declarations: [CarouselComponent, CarouselItem], + exports: [CarouselComponent, CarouselItem], + imports: [CommonModule, MatButtonModule], + schemas: [CUSTOM_ELEMENTS_SCHEMA] +}) +export class GfCarouselModule {} diff --git a/libs/ui/src/lib/carousel/index.ts b/libs/ui/src/lib/carousel/index.ts new file mode 100644 index 000000000..2e039a80b --- /dev/null +++ b/libs/ui/src/lib/carousel/index.ts @@ -0,0 +1 @@ +export * from './carousel.module';