Merged PR 2047: feat(carousel): convert to transformX with touch support and card animations

feat(carousel): convert to transformX with touch support and card animations

- Convert carousel from scroll-based to translate3d() transform positioning
- Add touch/swipe support with direct DOM manipulation for smooth 60fps performance
- Add mouse drag support for desktop navigation
- Implement hardware-accelerated transforms with will-change optimization
- Add disabled input to prevent navigation when needed
- Fix bounds calculation to use parent viewport width
- Add card stacking animation with translateY and rotation effects
- Remove shadow for cards beyond 3rd position in stacked mode
- Update tests with 4 new disabled state test cases (17 tests total)

Refs #5499

Related work items: #5499
This commit is contained in:
Lorenz Hilpert
2025-11-24 13:37:34 +00:00
committed by Nino Righi
parent 7f1cdf880f
commit 38de927c4e
11 changed files with 516 additions and 160 deletions

View File

@@ -1 +1,4 @@
/* Customer card styles - using Tailwind, no additional CSS needed */
/* Customer card styles - using Tailwind, no additional CSS needed */
:host {
@apply rounded-2xl overflow-hidden;
}

View File

@@ -1,6 +1,6 @@
<!-- Card container: 337×213px, rounded-2xl, shadow -->
<div
class="relative flex h-[14.8125rem] w-[21.0625rem] flex-col overflow-hidden rounded-2xl bg-isa-black shadow-card"
class="relative flex h-[14.8125rem] w-[21.0625rem] flex-col bg-isa-black"
[attr.data-what]="'customer-card'"
[attr.data-which]="card().code"
>

View File

@@ -0,0 +1,24 @@
import {
AfterViewInit,
Directive,
ElementRef,
inject,
signal,
} from '@angular/core';
@Directive({ selector: '[crmCardStackContainer]' })
export class CardStackContainerDirective implements AfterViewInit {
readonly elementRef = inject(ElementRef<HTMLElement>);
readonly centerX = signal(0);
ngAfterViewInit(): void {
this.centerX.set(this.getHorizontalCenter());
}
getHorizontalCenter(): number {
const el = this.elementRef.nativeElement;
const rect = el.getBoundingClientRect();
return rect.left + rect.width / 2;
}
}

View File

@@ -0,0 +1,38 @@
import {
AfterViewInit,
computed,
Directive,
ElementRef,
inject,
signal,
} from '@angular/core';
import { CardStackContainerDirective } from './card-stack-container.directive';
@Directive({
selector: '[crmCardStackDistance]',
host: {
'[style.--distance-to-center.px]': 'distanceToCenter()',
},
})
export class CardStackDistanceDirective implements AfterViewInit {
readonly container = inject(CardStackContainerDirective, { host: true });
readonly elementRef = inject(ElementRef<HTMLElement>);
distanceToCenter = computed(() => {
const containerCenterX = this.container.centerX();
const centerX = this.centerX();
return containerCenterX - centerX;
});
centerX = signal(0);
ngAfterViewInit(): void {
this.centerX.set(this.getHorizontalCenter());
}
getHorizontalCenter(): number {
const el = this.elementRef.nativeElement;
const rect = el.getBoundingClientRect();
return rect.left + rect.width / 2;
}
}

View File

@@ -1 +1,68 @@
/* Carousel container styles - using Tailwind and ui-carousel, no additional CSS needed */
/* Keyframes for card entrance animation */
@keyframes cardEntrance {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Keyframes for unstacking animation */
@keyframes unstack {
from {
transform: translateX(var(--distance-to-center));
}
to {
transform: translateX(0);
}
}
crm-customer-card {
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); /* Ease-out-back for slight overshoot */
box-shadow: 0 4px 24px 0 rgba(0, 0, 0, 0.25);
/* Initial entrance animation with staggered delay */
animation: cardEntrance 0.4s cubic-bezier(0.25, 1, 0.5, 1) backwards;
animation-delay: calc(var(--card-index, 0) * 80ms);
}
/* Unstacked state (default) */
crm-customer-card {
transform: translateX(0);
}
/* Stacked state transformations */
.stacked crm-customer-card {
transform: translateX(var(--distance-to-center));
}
.stacked crm-customer-card:nth-child(1) {
z-index: 3;
}
.stacked crm-customer-card:nth-child(2) {
transform: translateX(calc(var(--distance-to-center) - 3.6rem))
translateY(1rem) rotate(-2.538deg);
z-index: 2;
}
.stacked crm-customer-card:nth-child(3) {
transform: translateX(calc(var(--distance-to-center) + 3.6rem))
translateY(1rem) rotate(2.538deg);
z-index: 1;
}
.stacked crm-customer-card:nth-child(n + 4) {
box-shadow: none;
}
/* Respect user's motion preferences */
@media (prefers-reduced-motion: reduce) {
crm-customer-card {
animation: none;
transition-duration: 0.1s;
}
}

View File

@@ -1,12 +1,19 @@
<div class="relative" data-what="customer-cards-carousel">
<!-- Carousel with navigation arrows -->
<ui-carousel
[gap]="'1rem'"
[class.stacked]="stacked()"
[gap]="stacked() ? '0rem' : '1rem'"
[arrowAutoHide]="true"
[padding]="'0.75rem 0.5rem'"
crmCardStackContainer
[disabled]="stacked()"
>
@for (card of sortedCards(); track card.code) {
<crm-customer-card [card]="card" (cardLocked)="cardLocked.emit()" />
@for (card of sortedCards(); track card.code; let idx = $index) {
<crm-customer-card
[card]="card"
(cardLocked)="cardLocked.emit()"
crmCardStackDistance
[style.--card-index]="idx"
/>
}
</ui-carousel>
</div>

View File

@@ -1,7 +1,9 @@
import { Component, computed, input, output } from '@angular/core';
import { Component, computed, input, output, signal } from '@angular/core';
import { BonusCardInfo } from '@isa/crm/data-access';
import { CarouselComponent } from '@isa/ui/carousel';
import { CustomerCardComponent } from '../customer-card';
import { CardStackContainerDirective } from './card-stack-container.directive';
import { CardStackDistanceDirective } from './card-stack-distance.directive';
/**
* Carousel container for displaying multiple customer loyalty cards.
@@ -23,11 +25,21 @@ import { CustomerCardComponent } from '../customer-card';
*/
@Component({
selector: 'crm-customer-cards-carousel',
imports: [CarouselComponent, CustomerCardComponent],
imports: [
CarouselComponent,
CustomerCardComponent,
CardStackDistanceDirective,
CardStackContainerDirective,
],
templateUrl: './customer-cards-carousel.component.html',
styleUrl: './customer-cards-carousel.component.css',
host: {
'(click)': 'unStack()',
},
})
export class CustomerCardsCarouselComponent {
stacked = signal(true);
/**
* All bonus cards to display in carousel.
*/
@@ -51,4 +63,8 @@ export class CustomerCardsCarouselComponent {
return a.isActive ? -1 : 1;
});
});
unStack(): void {
this.stacked.set(false);
}
}