mirror of https://github.com/ghostfolio/ghostfolio
4 changed files with 231 additions and 0 deletions
@ -0,0 +1,16 @@ |
|||||
|
@use 'sass:map'; |
||||
|
@use '@angular/material' as mat; |
||||
|
|
||||
|
@mixin theme($theme) { |
||||
|
$primary: map.get($theme, primary); |
||||
|
$accent: map.get($theme, accent); |
||||
|
$warn: map.get($theme, warn); |
||||
|
$background: map.get($theme, background); |
||||
|
$foreground: map.get($theme, foreground); |
||||
|
|
||||
|
.docs-carousel-nav.mat-mdc-mini-fab { |
||||
|
background: mat.get-color-from-palette($background, background); |
||||
|
color: mat.get-color-from-palette($foreground, secondary-text) |
||||
|
} |
||||
|
|
||||
|
} |
@ -0,0 +1,16 @@ |
|||||
|
<button (click)="previous()" *ngIf="this.showPrevArrow" aria-hidden="true" tabindex="-1" |
||||
|
class="docs-carousel-nav docs-carousel-nav-prev" mat-mini-fab aria-label="previous"> |
||||
|
<mat-icon>navigate_before</mat-icon> |
||||
|
</button> |
||||
|
|
||||
|
<div #contentWrapper (keyup)="onKeydown($event)" |
||||
|
class="docs-carousel-content-wrapper" role="region"> |
||||
|
<div class="docs-carousel-content" role="list" tabindex="0" #list> |
||||
|
<ng-content></ng-content> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<button (click)="next()" *ngIf="this.showNextArrow" aria-hidden="true" tabindex="-1" |
||||
|
class="docs-carousel-nav docs-carousel-nav-next" mat-mini-fab aria-label="next"> |
||||
|
<mat-icon>navigate_next</mat-icon> |
||||
|
</button> |
@ -0,0 +1,37 @@ |
|||||
|
app-carousel { |
||||
|
display: block; |
||||
|
position: relative; |
||||
|
} |
||||
|
|
||||
|
.docs-carousel-content { |
||||
|
display: flex; |
||||
|
flex-direction: row; |
||||
|
outline: none; |
||||
|
transition: transform 0.5s ease-in-out; |
||||
|
|
||||
|
.animations-disabled & { |
||||
|
transition: none; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.docs-carousel-content-wrapper { |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
|
||||
|
[carousel-item] { |
||||
|
flex-shrink: 0; |
||||
|
} |
||||
|
|
||||
|
button.docs-carousel-nav { |
||||
|
position: absolute; |
||||
|
top: 50%; |
||||
|
transform: translateY(-50%); |
||||
|
} |
||||
|
|
||||
|
.docs-carousel-nav-prev { |
||||
|
left: -50px; |
||||
|
} |
||||
|
|
||||
|
.docs-carousel-nav-next { |
||||
|
right: -50px; |
||||
|
} |
@ -0,0 +1,162 @@ |
|||||
|
import { |
||||
|
AfterContentInit, |
||||
|
Component, |
||||
|
ContentChildren, |
||||
|
Directive, |
||||
|
ElementRef, |
||||
|
HostBinding, |
||||
|
Inject, |
||||
|
Input, |
||||
|
Optional, |
||||
|
QueryList, |
||||
|
ViewChild, |
||||
|
ViewEncapsulation, |
||||
|
} from '@angular/core'; |
||||
|
import {FocusableOption, FocusKeyManager} from '@angular/cdk/a11y'; |
||||
|
import {LEFT_ARROW, RIGHT_ARROW, TAB} from '@angular/cdk/keycodes'; |
||||
|
import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations'; |
||||
|
import {MatIconModule} from '@angular/material/icon'; |
||||
|
import {MatButtonModule} from '@angular/material/button'; |
||||
|
import {NgIf} from '@angular/common'; |
||||
|
|
||||
|
@Directive({ |
||||
|
selector: '[carousel-item]', |
||||
|
standalone: true, |
||||
|
}) |
||||
|
export class CarouselItem implements FocusableOption { |
||||
|
@HostBinding('attr.role') readonly role = 'listitem'; |
||||
|
@HostBinding('tabindex') tabindex = '-1'; |
||||
|
|
||||
|
constructor(readonly element: ElementRef<HTMLElement>) {} |
||||
|
|
||||
|
focus(): void { |
||||
|
this.element.nativeElement.focus({preventScroll: true}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Component({ |
||||
|
selector: 'app-carousel', |
||||
|
templateUrl: './carousel.html', |
||||
|
styleUrls: ['./carousel.scss'], |
||||
|
encapsulation: ViewEncapsulation.None, |
||||
|
standalone: true, |
||||
|
imports: [ |
||||
|
NgIf, |
||||
|
MatButtonModule, |
||||
|
MatIconModule, |
||||
|
], |
||||
|
}) |
||||
|
export class Carousel implements AfterContentInit { |
||||
|
@Input('aria-label') ariaLabel: string | undefined; |
||||
|
@ContentChildren(CarouselItem) items!: QueryList<CarouselItem>; |
||||
|
@ViewChild('list') list!: ElementRef<HTMLElement>; |
||||
|
@HostBinding('class.animations-disabled') readonly animationsDisabled: boolean; |
||||
|
position = 0; |
||||
|
showPrevArrow = false; |
||||
|
showNextArrow = true; |
||||
|
index = 0; |
||||
|
private _keyManager!: FocusKeyManager<CarouselItem>; |
||||
|
|
||||
|
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(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
constructor(@Optional() @Inject(ANIMATION_MODULE_TYPE) animationsModule?: string) { |
||||
|
this.animationsDisabled = animationsModule === 'NoopAnimations'; |
||||
|
} |
||||
|
|
||||
|
ngAfterContentInit(): void { |
||||
|
this._keyManager = new FocusKeyManager<CarouselItem>(this.items); |
||||
|
} |
||||
|
|
||||
|
/** Goes to the next set of items */ |
||||
|
next() { |
||||
|
for (let i = this.index; i < this.items.length; i++) { |
||||
|
if (this._isOutOfView(i)) { |
||||
|
this.index = i; |
||||
|
this._scrollToActiveItem(); |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** Goes to the previous set of items. */ |
||||
|
previous() { |
||||
|
for (let i = this.index; i > -1; i--) { |
||||
|
if (this._isOutOfView(i)) { |
||||
|
this.index = i; |
||||
|
this._scrollToActiveItem(); |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** Updates the `tabindex` of each of the items based on their active state. */ |
||||
|
private _updateItemTabIndices() { |
||||
|
this.items.forEach((item: CarouselItem) => { |
||||
|
if (this._keyManager != null) { |
||||
|
item.tabindex = item === this._keyManager.activeItem ? '0' : '-1'; |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/** Scrolls an item into the viewport. */ |
||||
|
private _scrollToActiveItem() { |
||||
|
if (!this._isOutOfView(this.index)) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const itemsArray = this.items.toArray(); |
||||
|
let targetItemIndex = this.index; |
||||
|
|
||||
|
// Only shift the carousel by one if we're going forwards. This
|
||||
|
// looks better compared to moving the carousel by an entire page.
|
||||
|
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; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** Checks whether an item at a specific index is outside of the viewport. */ |
||||
|
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 |
||||
|
); |
||||
|
} |
||||
|
} |
Loading…
Reference in new issue