mirror of https://github.com/ghostfolio/ghostfolio
12 changed files with 273 additions and 266 deletions
@ -1,16 +0,0 @@ |
|||||
@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) |
|
||||
} |
|
||||
|
|
||||
} |
|
@ -1,14 +1,16 @@ |
|||||
import { FocusableOption } from "@angular/cdk/a11y"; |
import { FocusableOption } from '@angular/cdk/a11y'; |
||||
import { ElementRef, HostBinding } from "@angular/core"; |
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'; |
||||
|
|
||||
export class CarouselItem implements FocusableOption { |
public constructor(readonly element: ElementRef<HTMLElement>) {} |
||||
@HostBinding('attr.role') readonly role = 'listitem'; |
|
||||
@HostBinding('tabindex') tabindex = '-1'; |
public focus(): void { |
||||
|
this.element.nativeElement.focus({ preventScroll: true }); |
||||
constructor(readonly element: ElementRef<HTMLElement>) {} |
} |
||||
|
} |
||||
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'; |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
} |
@ -1,16 +0,0 @@ |
|||||
<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,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,37 +0,0 @@ |
|||||
gf-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; |
|
||||
} |
|
@ -1,144 +0,0 @@ |
|||||
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'; |
|
||||
import { CarouselItem } from './carousel-item.directive'; |
|
||||
|
|
||||
@Component({ |
|
||||
selector: 'gf-carousel', |
|
||||
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 |
|
||||
); |
|
||||
} |
|
||||
} |
|
@ -0,0 +1 @@ |
|||||
|
export * from './carousel.module'; |
Loading…
Reference in new issue