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