mirror of https://github.com/ghostfolio/ghostfolio
Browse Source
* Create carousel component for testimonials * Update changelog --------- Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>pull/2441/head
Subhajit Ghosh
1 year ago
committed by
GitHub
9 changed files with 274 additions and 20 deletions
@ -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 }); |
|||
} |
|||
} |
@ -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> |
@ -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; |
|||
} |
|||
} |
|||
} |
@ -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'; |
|||
} |
|||
}); |
|||
} |
|||
} |
@ -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 {} |
@ -0,0 +1 @@ |
|||
export * from './carousel.module'; |
Loading…
Reference in new issue