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
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