simplified carousel
All checks were successful
publish.yml / publish (push) Successful in 1m4s

This commit is contained in:
2026-04-24 00:08:08 +02:00
parent f7eceda442
commit 7108f22e8b
3 changed files with 33 additions and 336 deletions

View File

@@ -28,7 +28,6 @@ h2 {
.stack-carousel-shell {
--carousel-nav-space: 4.25rem;
--carousel-edge-fade-size: clamp(1.25rem, 3vw, 2.75rem);
position: relative;
width: calc(100% + (var(--carousel-nav-space) * 2));
margin-inline: calc(var(--carousel-nav-space) * -1);
@@ -48,15 +47,12 @@ h2 {
overflow-x: auto;
width: 100%;
padding: 0.2rem;
scroll-snap-type: none;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
scrollbar-width: thin;
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;
@@ -181,7 +177,6 @@ h2 {
@media (max-width: 760px) {
.stack-carousel-shell {
--carousel-nav-space: 0;
--carousel-edge-fade-size: 0.6rem;
width: 100%;
margin-inline: 0;
}

View File

@@ -6,29 +6,19 @@
<div
class="stack-carousel-shell"
data-disable-bg-pointer
(mouseenter)="onHoverStart()"
(mouseleave)="onHoverEnd()"
(focusin)="onFocusIn()"
(focusout)="onCarouselFocusOut($event)"
(click)="onInteractionClick()"
>
<button class="carousel-btn" type="button" aria-label="Previous technologies" (click)="scrollStack(-1)">
<span aria-hidden="true">&larr;</span>
</button>
<div class="stack-carousel-column" [class.has-left-fade]="showLeftFade" [class.has-right-fade]="showRightFade">
<div class="stack-carousel-column">
<div
class="stack-carousel"
[class.is-auto-scrolling]="isAutoScrolling"
#stackCarousel
(scroll)="onCarouselScroll()"
(touchstart)="onTouchStart()"
(touchend)="onTouchEnd()"
(touchcancel)="onTouchCancel()"
role="region"
aria-label="Tech stack carousel"
>
@for (skill of renderedSkills; track $index) {
@for (skill of skills; track skill.name) {
<app-card cardClass="skill-card stack-slide">
<div class="skill-heading">
<span class="skill-icon" role="img" [attr.aria-label]="skill.name + ' logo'">

View File

@@ -17,29 +17,8 @@ interface Skill {
})
export class TechStackCarousel implements AfterViewInit, OnDestroy {
@ViewChild('stackCarousel') private readonly stackCarousel?: ElementRef<HTMLElement>;
private autoScrollFrameId: number | null = null;
private startAutoScrollTimeout: ReturnType<typeof setTimeout> | null = null;
private delayedResumeTimeout: ReturnType<typeof setTimeout> | null = null;
private delayedHoverPauseTimeout: ReturnType<typeof setTimeout> | null = null;
private delayedResumeAt = 0;
private isHoveringCarousel = false;
private lastAutoScrollTime = 0;
private virtualScrollLeft = 0;
private currentAutoScrollSpeedPxPerSecond = 0;
private targetAutoScrollSpeedPxPerSecond = 0;
private isPausePendingStop = false;
private isTouchInteracting = false;
private isProgrammaticScrollUpdate = false;
private lastFadeStateUpdateTime = 0;
private readonly autoScrollSpeedPxPerSecond = 48;
private readonly speedRampDurationMs = 420;
private readonly speedStopThresholdPxPerSecond = 0.5;
private readonly touchScrollResumeDelayMs = 1200;
private readonly hoverPauseDelayMs = 300;
private readonly fadeUpdateIntervalMs = 96;
isAutoScrolling = false;
showLeftFade = false;
showRightFade = false;
private autoScrollIntervalId: ReturnType<typeof setInterval> | null = null;
private readonly autoScrollIntervalMs = 5000;
readonly skills: Skill[] = [
{ name: 'Angular', category: 'Frontend', level: 'Advanced', logoUrl: '/tech-logos/angular.svg', fallbackLabel: 'NG' },
@@ -56,38 +35,12 @@ 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;
if (prefersReducedMotion) {
this.updateFadeState();
return;
}
// Defer start to the next task to avoid NG0100 in dev mode.
this.startAutoScrollTimeout = setTimeout(() => {
this.startAutoScrollTimeout = null;
this.updateFadeState();
this.resumeAutoScroll();
}, 0);
document.addEventListener('visibilitychange', this.onVisibilityChange);
this.startAutoScroll();
}
ngOnDestroy(): void {
if (this.startAutoScrollTimeout !== null) {
clearTimeout(this.startAutoScrollTimeout);
this.startAutoScrollTimeout = null;
}
this.clearScheduledResume();
this.clearScheduledHoverPause();
this.pauseAutoScroll();
document.removeEventListener('visibilitychange', this.onVisibilityChange);
this.isAutoScrolling = false;
this.stopAutoScroll();
}
scrollStack(direction: -1 | 1): void {
@@ -96,157 +49,17 @@ export class TechStackCarousel implements AfterViewInit, OnDestroy {
return;
}
const maxScrollLeft = Math.max(carousel.scrollWidth - carousel.clientWidth, 0);
if (maxScrollLeft <= 0) {
return;
}
if (direction === 1 && carousel.scrollLeft >= maxScrollLeft) {
carousel.scrollTo({ left: 0, behavior: 'auto' });
return;
}
carousel.scrollBy({ left: this.getScrollStep(carousel) * direction, behavior: 'smooth' });
requestAnimationFrame(() => this.updateFadeState(carousel));
}
onCarouselScroll(): void {
if (this.isProgrammaticScrollUpdate) {
this.isProgrammaticScrollUpdate = false;
this.updateFadeState();
return;
}
// Keep autoscroll from fighting touch/momentum scrolling on mobile.
this.pauseAutoScrollImmediately();
this.scheduleResume(this.touchScrollResumeDelayMs);
this.updateFadeState();
}
onTouchStart(): void {
this.isTouchInteracting = true;
this.clearScheduledResume();
this.pauseAutoScrollImmediately();
}
onTouchEnd(): void {
this.isTouchInteracting = false;
this.scheduleResume(this.touchScrollResumeDelayMs);
}
onTouchCancel(): void {
this.isTouchInteracting = false;
this.scheduleResume(this.touchScrollResumeDelayMs);
}
onHoverStart(): void {
this.isHoveringCarousel = true;
this.clearScheduledResume();
this.clearScheduledHoverPause();
this.delayedHoverPauseTimeout = setTimeout(() => {
this.delayedHoverPauseTimeout = null;
if (!this.isHoveringCarousel) {
return;
}
this.pauseAutoScroll();
}, this.hoverPauseDelayMs);
}
onHoverEnd(): void {
this.isHoveringCarousel = false;
this.clearScheduledHoverPause();
this.scheduleResume(1000);
}
onFocusIn(): void {
this.pauseAutoScroll();
this.clearScheduledResume();
}
onInteractionClick(): void {
this.pauseAutoScroll();
this.scheduleResume(2000);
}
pauseAutoScroll(): void {
this.targetAutoScrollSpeedPxPerSecond = 0;
this.isPausePendingStop = true;
this.syncAutoScrollState();
if (this.autoScrollFrameId === null) {
this.currentAutoScrollSpeedPxPerSecond = 0;
this.lastAutoScrollTime = 0;
}
}
resumeAutoScroll(): void {
if (document.hidden) {
return;
}
if (this.isTouchInteracting) {
this.scheduleResume(this.touchScrollResumeDelayMs);
return;
}
this.isPausePendingStop = false;
this.targetAutoScrollSpeedPxPerSecond = this.autoScrollSpeedPxPerSecond;
this.syncAutoScrollState();
if (this.autoScrollFrameId !== null) {
return;
}
const step = (timestamp: number) => {
const carousel = this.stackCarousel?.nativeElement;
if (!carousel) {
this.autoScrollFrameId = null;
this.lastAutoScrollTime = 0;
this.virtualScrollLeft = 0;
this.currentAutoScrollSpeedPxPerSecond = 0;
this.targetAutoScrollSpeedPxPerSecond = 0;
this.isPausePendingStop = false;
this.isAutoScrolling = false;
return;
}
if (this.lastAutoScrollTime === 0) {
const loopWidth = this.getLoopWidth(carousel);
this.virtualScrollLeft = loopWidth > 0 ? carousel.scrollLeft : 0;
}
const deltaMs = this.lastAutoScrollTime > 0 ? Math.min(timestamp - this.lastAutoScrollTime, 40) : 16;
this.lastAutoScrollTime = timestamp;
this.updateAutoScrollSpeed(deltaMs);
if (this.currentAutoScrollSpeedPxPerSecond > this.speedStopThresholdPxPerSecond) {
this.autoAdvanceStack(carousel, deltaMs, this.currentAutoScrollSpeedPxPerSecond);
}
if (timestamp - this.lastFadeStateUpdateTime >= this.fadeUpdateIntervalMs) {
this.updateFadeState(carousel);
this.lastFadeStateUpdateTime = timestamp;
}
this.syncAutoScrollState();
if (this.isPausePendingStop && this.currentAutoScrollSpeedPxPerSecond <= this.speedStopThresholdPxPerSecond) {
this.currentAutoScrollSpeedPxPerSecond = 0;
this.targetAutoScrollSpeedPxPerSecond = 0;
this.lastAutoScrollTime = 0;
this.autoScrollFrameId = null;
this.isPausePendingStop = false;
this.syncAutoScrollState();
return;
}
this.autoScrollFrameId = requestAnimationFrame(step);
};
this.autoScrollFrameId = requestAnimationFrame(step);
}
onCarouselFocusOut(event: FocusEvent): void {
const next = event.relatedTarget as Node | null;
const shell = event.currentTarget as HTMLElement | null;
if (!shell?.contains(next)) {
this.scheduleResume(1000);
}
}
onLogoError(event: Event): void {
@@ -255,128 +68,27 @@ export class TechStackCarousel implements AfterViewInit, OnDestroy {
wrapper?.classList.add('is-fallback');
}
private scheduleResume(delayMs: number): void {
const nextResumeAt = Date.now() + delayMs;
if (nextResumeAt <= this.delayedResumeAt) {
return;
}
this.delayedResumeAt = nextResumeAt;
if (this.delayedResumeTimeout !== null) {
clearTimeout(this.delayedResumeTimeout);
}
this.delayedResumeTimeout = setTimeout(() => {
this.delayedResumeTimeout = null;
this.delayedResumeAt = 0;
this.resumeAutoScroll();
}, delayMs);
}
private clearScheduledResume(): void {
if (this.delayedResumeTimeout !== null) {
clearTimeout(this.delayedResumeTimeout);
this.delayedResumeTimeout = null;
}
this.delayedResumeAt = 0;
}
private clearScheduledHoverPause(): void {
if (this.delayedHoverPauseTimeout !== null) {
clearTimeout(this.delayedHoverPauseTimeout);
this.delayedHoverPauseTimeout = null;
}
}
private updateAutoScrollSpeed(deltaMs: number): void {
if (deltaMs <= 0) {
return;
}
const speedDelta = this.targetAutoScrollSpeedPxPerSecond - this.currentAutoScrollSpeedPxPerSecond;
if (Math.abs(speedDelta) <= this.speedStopThresholdPxPerSecond) {
this.currentAutoScrollSpeedPxPerSecond = this.targetAutoScrollSpeedPxPerSecond;
return;
}
const maxStep = (this.autoScrollSpeedPxPerSecond * deltaMs) / this.speedRampDurationMs;
const appliedStep = Math.sign(speedDelta) * Math.min(Math.abs(speedDelta), maxStep);
this.currentAutoScrollSpeedPxPerSecond += appliedStep;
}
private autoAdvanceStack(carousel: HTMLElement, deltaMs: number, speedPxPerSecond: number): void {
const loopWidth = this.getLoopWidth(carousel);
if (loopWidth <= 0) {
return;
}
const nextVirtualScrollLeft = this.virtualScrollLeft + (speedPxPerSecond * deltaMs) / 1000;
// Normalize only when moving past the duplicated track boundary to avoid abrupt remaps on quick re-entry.
this.virtualScrollLeft = nextVirtualScrollLeft >= loopWidth ? nextVirtualScrollLeft - loopWidth : nextVirtualScrollLeft;
this.isProgrammaticScrollUpdate = true;
carousel.scrollLeft = this.virtualScrollLeft;
}
private getLoopWidth(carousel: HTMLElement): number {
return carousel.scrollWidth / 2;
}
private updateFadeState(carousel = this.stackCarousel?.nativeElement): void {
if (!carousel) {
this.showLeftFade = false;
this.showRightFade = false;
return;
}
const maxScroll = Math.max(carousel.scrollWidth - carousel.clientWidth, 0);
if (maxScroll <= 1) {
this.showLeftFade = false;
this.showRightFade = false;
return;
}
const left = carousel.scrollLeft;
this.showLeftFade = left > 1;
this.showRightFade = left < maxScroll - 1;
}
private getScrollStep(carousel: HTMLElement): number {
return Math.max(carousel.clientWidth * 0.82, 260);
}
private syncAutoScrollState(): void {
this.isAutoScrolling =
this.targetAutoScrollSpeedPxPerSecond > this.speedStopThresholdPxPerSecond ||
this.currentAutoScrollSpeedPxPerSecond > this.speedStopThresholdPxPerSecond;
}
private pauseAutoScrollImmediately(): void {
this.clearScheduledResume();
if (this.autoScrollFrameId !== null) {
cancelAnimationFrame(this.autoScrollFrameId);
this.autoScrollFrameId = null;
}
this.isPausePendingStop = false;
this.currentAutoScrollSpeedPxPerSecond = 0;
this.targetAutoScrollSpeedPxPerSecond = 0;
this.lastAutoScrollTime = 0;
this.lastFadeStateUpdateTime = 0;
this.syncAutoScrollState();
}
private readonly onVisibilityChange = (): void => {
if (document.hidden) {
this.pauseAutoScrollImmediately();
private startAutoScroll(): void {
if (this.autoScrollIntervalId !== null) {
return;
}
this.scheduleResume(320);
};
this.autoScrollIntervalId = setInterval(() => {
this.scrollStack(1);
}, this.autoScrollIntervalMs);
}
private stopAutoScroll(): void {
if (this.autoScrollIntervalId === null) {
return;
}
clearInterval(this.autoScrollIntervalId);
this.autoScrollIntervalId = null;
}
}