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 { ElementRef, HostBinding } from "@angular/core"; |
|||
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'; |
|||
|
|||
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}); |
|||
} |
|||
} |
|||
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'; |
|||
} |
|||
}); |
|||
} |
|||
} |
@ -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