This commit is contained in:
@@ -50,6 +50,10 @@ h2 {
|
||||
scrollbar-color: rgba(88, 166, 255, 0.45) rgba(33, 38, 45, 0.65);
|
||||
}
|
||||
|
||||
.stack-carousel.is-auto-scrolling {
|
||||
scroll-snap-type: none;
|
||||
}
|
||||
|
||||
.stack-slide {
|
||||
flex: 0 0 min(320px, calc(100% - 0.2rem));
|
||||
scroll-snap-align: start;
|
||||
|
||||
@@ -18,8 +18,14 @@
|
||||
</button>
|
||||
|
||||
<div class="stack-carousel-column">
|
||||
<div class="stack-carousel" #stackCarousel role="region" aria-label="Tech stack carousel">
|
||||
@for (skill of skills; track skill.name) {
|
||||
<div
|
||||
class="stack-carousel"
|
||||
[class.is-auto-scrolling]="isAutoScrolling"
|
||||
#stackCarousel
|
||||
role="region"
|
||||
aria-label="Tech stack carousel"
|
||||
>
|
||||
@for (skill of renderedSkills; track $index) {
|
||||
<app-card cardClass="skill-card stack-slide">
|
||||
<div class="skill-heading">
|
||||
<span class="skill-icon" role="img" [attr.aria-label]="skill.name + ' logo'">
|
||||
|
||||
@@ -17,7 +17,12 @@ interface Skill {
|
||||
})
|
||||
export class TechStackCarousel implements AfterViewInit, OnDestroy {
|
||||
@ViewChild('stackCarousel') private readonly stackCarousel?: ElementRef<HTMLElement>;
|
||||
private autoScrollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private autoScrollFrameId: number | null = null;
|
||||
private startAutoScrollTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private lastAutoScrollTime = 0;
|
||||
private virtualScrollLeft = 0;
|
||||
private readonly autoScrollSpeedPxPerSecond = 72;
|
||||
isAutoScrolling = false;
|
||||
|
||||
readonly skills: Skill[] = [
|
||||
{ name: 'Angular', category: 'Frontend', level: 'Advanced', logoUrl: '/tech-logos/angular.svg', fallbackLabel: 'NG' },
|
||||
@@ -34,6 +39,8 @@ export class TechStackCarousel implements AfterViewInit, OnDestroy {
|
||||
{ name: 'Flutter', category: 'Mobile', level: 'Advanced', logoUrl: '/tech-logos/flutter.svg', fallbackLabel: 'FL' },
|
||||
];
|
||||
|
||||
readonly renderedSkills: Skill[] = [...this.skills, ...this.skills];
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
const prefersReducedMotion =
|
||||
typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
@@ -42,11 +49,21 @@ export class TechStackCarousel implements AfterViewInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
this.resumeAutoScroll();
|
||||
// Defer start to the next task to avoid NG0100 in dev mode.
|
||||
this.startAutoScrollTimeout = setTimeout(() => {
|
||||
this.startAutoScrollTimeout = null;
|
||||
this.resumeAutoScroll();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.startAutoScrollTimeout !== null) {
|
||||
clearTimeout(this.startAutoScrollTimeout);
|
||||
this.startAutoScrollTimeout = null;
|
||||
}
|
||||
|
||||
this.pauseAutoScroll();
|
||||
this.isAutoScrolling = false;
|
||||
}
|
||||
|
||||
scrollStack(direction: -1 | 1): void {
|
||||
@@ -59,22 +76,45 @@ export class TechStackCarousel implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
pauseAutoScroll(): void {
|
||||
if (!this.autoScrollTimer) {
|
||||
if (this.autoScrollFrameId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearInterval(this.autoScrollTimer);
|
||||
this.autoScrollTimer = null;
|
||||
cancelAnimationFrame(this.autoScrollFrameId);
|
||||
this.autoScrollFrameId = null;
|
||||
this.lastAutoScrollTime = 0;
|
||||
}
|
||||
|
||||
resumeAutoScroll(): void {
|
||||
if (this.autoScrollTimer) {
|
||||
if (this.autoScrollFrameId !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.autoScrollTimer = setInterval(() => {
|
||||
this.autoAdvanceStack();
|
||||
}, 3200);
|
||||
this.isAutoScrolling = true;
|
||||
|
||||
const step = (timestamp: number) => {
|
||||
const carousel = this.stackCarousel?.nativeElement;
|
||||
if (!carousel) {
|
||||
this.autoScrollFrameId = null;
|
||||
this.lastAutoScrollTime = 0;
|
||||
this.virtualScrollLeft = 0;
|
||||
this.isAutoScrolling = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.lastAutoScrollTime === 0) {
|
||||
const loopWidth = this.getLoopWidth(carousel);
|
||||
this.virtualScrollLeft = loopWidth > 0 ? carousel.scrollLeft % loopWidth : 0;
|
||||
}
|
||||
|
||||
const deltaMs = this.lastAutoScrollTime > 0 ? Math.min(timestamp - this.lastAutoScrollTime, 40) : 16;
|
||||
this.lastAutoScrollTime = timestamp;
|
||||
|
||||
this.autoAdvanceStack(carousel, deltaMs);
|
||||
this.autoScrollFrameId = requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
this.autoScrollFrameId = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
onCarouselFocusOut(event: FocusEvent): void {
|
||||
@@ -92,23 +132,22 @@ export class TechStackCarousel implements AfterViewInit, OnDestroy {
|
||||
wrapper?.classList.add('is-fallback');
|
||||
}
|
||||
|
||||
private autoAdvanceStack(): void {
|
||||
const carousel = this.stackCarousel?.nativeElement;
|
||||
if (!carousel) {
|
||||
private autoAdvanceStack(carousel: HTMLElement, deltaMs: number): void {
|
||||
const loopWidth = this.getLoopWidth(carousel);
|
||||
if (loopWidth <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxScroll = carousel.scrollWidth - carousel.clientWidth;
|
||||
if (maxScroll <= 0) {
|
||||
return;
|
||||
this.virtualScrollLeft += (this.autoScrollSpeedPxPerSecond * deltaMs) / 1000;
|
||||
if (this.virtualScrollLeft >= loopWidth) {
|
||||
this.virtualScrollLeft %= loopWidth;
|
||||
}
|
||||
|
||||
if (carousel.scrollLeft >= maxScroll - 4) {
|
||||
carousel.scrollTo({ left: 0, behavior: 'smooth' });
|
||||
return;
|
||||
}
|
||||
carousel.scrollLeft = this.virtualScrollLeft;
|
||||
}
|
||||
|
||||
carousel.scrollBy({ left: this.getScrollStep(carousel), behavior: 'smooth' });
|
||||
private getLoopWidth(carousel: HTMLElement): number {
|
||||
return carousel.scrollWidth / 2;
|
||||
}
|
||||
|
||||
private getScrollStep(carousel: HTMLElement): number {
|
||||
|
||||
Reference in New Issue
Block a user