Create carousel component for testimonials (#2394)
* Create carousel component for testimonials * Update changelog --------- Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
This commit is contained in:
parent
02b433eb1e
commit
46614a7c24
@ -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 support for notes in the activities import
|
||||||
- Added the application version to the endpoint `GET api/v1/admin`
|
- Added the application version to the endpoint `GET api/v1/admin`
|
||||||
|
- Introduced a carousel component for the testimonial section on the landing page
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
@ -320,31 +320,36 @@
|
|||||||
|
|
||||||
<div class="row my-5">
|
<div class="row my-5">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<h2 class="h4 mb-1 text-center" i18n>
|
<h2 class="h4 mb-3 text-center" i18n>
|
||||||
What our <strong>users</strong> are saying
|
What our <strong>users</strong> are saying
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div *ngFor="let testimonial of testimonials" class="col-md-6">
|
<div class="col-md-8 offset-md-2">
|
||||||
<div class="d-flex flex-row py-3">
|
<gf-carousel [aria-label]="'Testimonials'">
|
||||||
<div class="d-flex justify-content-center">
|
<div *ngFor="let testimonial of testimonials" gf-carousel-item>
|
||||||
<gf-logo
|
<div class="d-flex px-3">
|
||||||
class="mr-3 mt-2 pt-1"
|
<gf-logo
|
||||||
size="medium"
|
class="mr-3 mt-2 pt-1"
|
||||||
[showLabel]="false"
|
size="medium"
|
||||||
></gf-logo>
|
[showLabel]="false"
|
||||||
</div>
|
></gf-logo>
|
||||||
<div>
|
<div>
|
||||||
<div>{{ testimonial.quote }}</div>
|
<div>{{ testimonial.quote }}</div>
|
||||||
<div class="mt-2 text-muted">
|
<div class="mt-2 text-muted">
|
||||||
—
|
—
|
||||||
<a *ngIf="testimonial.url" target="_blank" [href]="testimonial.url"
|
<a
|
||||||
>{{ testimonial.author }}</a
|
*ngIf="testimonial.url"
|
||||||
>
|
target="_blank"
|
||||||
<span *ngIf="!testimonial.url">{{ testimonial.author }}</span>, {{
|
[href]="testimonial.url"
|
||||||
testimonial.country }}
|
>{{ testimonial.author }}</a
|
||||||
|
>
|
||||||
|
<span *ngIf="!testimonial.url">{{ testimonial.author }}</span>,
|
||||||
|
{{ testimonial.country }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</gf-carousel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
|
|||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module';
|
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 { GfLogoModule } from '@ghostfolio/ui/logo';
|
||||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||||
|
|
||||||
@ -14,6 +15,7 @@ import { LandingPageComponent } from './landing-page.component';
|
|||||||
declarations: [LandingPageComponent],
|
declarations: [LandingPageComponent],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
GfCarouselModule,
|
||||||
GfLogoModule,
|
GfLogoModule,
|
||||||
GfValueModule,
|
GfValueModule,
|
||||||
GfWorldMapChartModule,
|
GfWorldMapChartModule,
|
||||||
|
16
libs/ui/src/lib/carousel/carousel-item.directive.ts
Normal file
16
libs/ui/src/lib/carousel/carousel-item.directive.ts
Normal file
@ -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<HTMLElement>) {}
|
||||||
|
|
||||||
|
public focus(): void {
|
||||||
|
this.element.nativeElement.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
}
|
34
libs/ui/src/lib/carousel/carousel.component.html
Normal file
34
libs/ui/src/lib/carousel/carousel.component.html
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<button
|
||||||
|
*ngIf="this.showPrevArrow"
|
||||||
|
aria-hidden="true"
|
||||||
|
aria-label="previous"
|
||||||
|
class="carousel-nav carousel-nav-prev no-min-width position-absolute"
|
||||||
|
mat-stroked-button
|
||||||
|
tabindex="-1"
|
||||||
|
(click)="previous()"
|
||||||
|
>
|
||||||
|
<ion-icon name="chevron-back-outline"></ion-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
#contentWrapper
|
||||||
|
class="overflow-hidden"
|
||||||
|
role="region"
|
||||||
|
(keyup)="onKeydown($event)"
|
||||||
|
>
|
||||||
|
<div #list class="d-flex carousel-content" role="list" tabindex="0">
|
||||||
|
<ng-content></ng-content>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
*ngIf="this.showNextArrow"
|
||||||
|
aria-hidden="true"
|
||||||
|
aria-label="next"
|
||||||
|
class="carousel-nav carousel-nav-next no-min-width position-absolute"
|
||||||
|
mat-stroked-button
|
||||||
|
tabindex="-1"
|
||||||
|
(click)="next()"
|
||||||
|
>
|
||||||
|
<ion-icon name="chevron-forward-outline"></ion-icon>
|
||||||
|
</button>
|
34
libs/ui/src/lib/carousel/carousel.component.scss
Normal file
34
libs/ui/src/lib/carousel/carousel.component.scss
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
147
libs/ui/src/lib/carousel/carousel.component.ts
Normal file
147
libs/ui/src/lib/carousel/carousel.component.ts
Normal file
@ -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<CarouselItem>;
|
||||||
|
|
||||||
|
@HostBinding('class.animations-disabled')
|
||||||
|
public readonly animationsDisabled: boolean;
|
||||||
|
|
||||||
|
@Input('aria-label') public ariaLabel: string | undefined;
|
||||||
|
|
||||||
|
@ViewChild('list') public list!: ElementRef<HTMLElement>;
|
||||||
|
|
||||||
|
public showPrevArrow = false;
|
||||||
|
public showNextArrow = true;
|
||||||
|
|
||||||
|
private index = 0;
|
||||||
|
private keyManager!: FocusKeyManager<CarouselItem>;
|
||||||
|
private position = 0;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
@Optional() @Inject(ANIMATION_MODULE_TYPE) animationsModule?: string
|
||||||
|
) {
|
||||||
|
this.animationsDisabled = animationsModule === 'NoopAnimations';
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngAfterContentInit() {
|
||||||
|
this.keyManager = new FocusKeyManager<CarouselItem>(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';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
14
libs/ui/src/lib/carousel/carousel.module.ts
Normal file
14
libs/ui/src/lib/carousel/carousel.module.ts
Normal file
@ -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 {}
|
1
libs/ui/src/lib/carousel/index.ts
Normal file
1
libs/ui/src/lib/carousel/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './carousel.module';
|
Loading…
x
Reference in New Issue
Block a user