mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
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:
committed by
Nino Righi
parent
7f1cdf880f
commit
38de927c4e
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
.ui-carousel {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: visible;
|
||||
|
||||
// Focus outline for keyboard navigation
|
||||
&:focus {
|
||||
@@ -13,32 +17,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
.ui-carousel__wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: visible; // Allow items to overflow and be visible at edges
|
||||
}
|
||||
|
||||
.ui-carousel__container {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
overflow-y: visible; // Allow vertical overflow to be visible
|
||||
scroll-behavior: smooth;
|
||||
width: 100%;
|
||||
box-sizing: border-box; // Padding is inset, maintains original size
|
||||
flex-wrap: nowrap;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
touch-action: pan-y; // Allow vertical scrolling, prevent horizontal scroll
|
||||
width: max-content; // Container expands to fit all children
|
||||
|
||||
// Hide scrollbar while maintaining scroll functionality
|
||||
scrollbar-width: none; // Firefox
|
||||
-ms-overflow-style: none; // IE/Edge
|
||||
transition: transform 0.3s linear;
|
||||
will-change: transform; // Optimize for hardware acceleration
|
||||
backface-visibility: hidden; // Prevent flickering
|
||||
-webkit-backface-visibility: hidden;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none; // Chrome/Safari
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
// Enable touch scrolling on mobile
|
||||
-webkit-overflow-scrolling: touch;
|
||||
.ui-carousel__container--dragging {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.ui-carousel__button {
|
||||
|
||||
@@ -1,40 +1,39 @@
|
||||
<div class="ui-carousel__wrapper">
|
||||
@if (showLeftArrow()) {
|
||||
<button
|
||||
uiIconButton
|
||||
class="ui-carousel__button ui-carousel__button--left"
|
||||
type="button"
|
||||
name="isaActionChevronLeft"
|
||||
size="large"
|
||||
color="tertiary"
|
||||
data-what="button"
|
||||
data-which="carousel-previous"
|
||||
(click)="scrollToPrevious()"
|
||||
[attr.aria-label]="'Previous'"
|
||||
></button>
|
||||
}
|
||||
|
||||
<div
|
||||
#scrollContainer
|
||||
class="ui-carousel__container"
|
||||
[style.gap]="gap()"
|
||||
[style.padding]="padding()"
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
|
||||
@if (showRightArrow()) {
|
||||
<button
|
||||
uiIconButton
|
||||
class="ui-carousel__button ui-carousel__button--right"
|
||||
type="button"
|
||||
name="isaActionChevronRight"
|
||||
size="large"
|
||||
color="tertiary"
|
||||
data-what="button"
|
||||
data-which="carousel-next"
|
||||
(click)="scrollToNext()"
|
||||
[attr.aria-label]="'Next'"
|
||||
></button>
|
||||
}
|
||||
</div>
|
||||
@if (!disabled() && canScrollLeft()) {
|
||||
<button
|
||||
uiIconButton
|
||||
class="ui-carousel__button ui-carousel__button--left"
|
||||
type="button"
|
||||
name="isaActionChevronLeft"
|
||||
size="large"
|
||||
color="tertiary"
|
||||
data-what="button"
|
||||
data-which="carousel-previous"
|
||||
(click)="navigateToPrevious()"
|
||||
[attr.aria-label]="'Previous'"
|
||||
></button>
|
||||
}
|
||||
|
||||
<div
|
||||
#container
|
||||
class="ui-carousel__container"
|
||||
[class.ui-carousel__container--dragging]="isDragging()"
|
||||
[style.gap]="gap()"
|
||||
[style.transform]="transformX()"
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
|
||||
@if (!disabled() && canScrollRight()) {
|
||||
<button
|
||||
uiIconButton
|
||||
class="ui-carousel__button ui-carousel__button--right"
|
||||
type="button"
|
||||
name="isaActionChevronRight"
|
||||
size="large"
|
||||
color="tertiary"
|
||||
data-what="button"
|
||||
data-which="carousel-next"
|
||||
(click)="navigateToNext()"
|
||||
[attr.aria-label]="'Next'"
|
||||
></button>
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ describe('CarouselComponent', () => {
|
||||
it('should have default input values', () => {
|
||||
expect(component.gap()).toBe('1rem');
|
||||
expect(component.arrowAutoHide()).toBe(true);
|
||||
expect(component.disabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should apply auto-hide class when arrowAutoHide is true', () => {
|
||||
@@ -154,8 +155,8 @@ describe('CarouselComponent keyboard navigation', () => {
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should call scrollToPrevious on ArrowLeft keydown', () => {
|
||||
const spy = vi.spyOn(component, 'scrollToPrevious');
|
||||
it('should call navigateToPrevious on ArrowLeft keydown', () => {
|
||||
const spy = vi.spyOn(component, 'navigateToPrevious');
|
||||
const event = new KeyboardEvent('keydown', { key: 'ArrowLeft' });
|
||||
|
||||
const hostElement = fixture.nativeElement as HTMLElement;
|
||||
@@ -164,8 +165,8 @@ describe('CarouselComponent keyboard navigation', () => {
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call scrollToNext on ArrowRight keydown', () => {
|
||||
const spy = vi.spyOn(component, 'scrollToNext');
|
||||
it('should call navigateToNext on ArrowRight keydown', () => {
|
||||
const spy = vi.spyOn(component, 'navigateToNext');
|
||||
const event = new KeyboardEvent('keydown', { key: 'ArrowRight' });
|
||||
|
||||
const hostElement = fixture.nativeElement as HTMLElement;
|
||||
@@ -174,3 +175,60 @@ describe('CarouselComponent keyboard navigation', () => {
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CarouselComponent disabled state', () => {
|
||||
let component: CarouselComponent;
|
||||
let fixture: ComponentFixture<CarouselComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CarouselComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(CarouselComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should hide buttons when disabled', () => {
|
||||
fixture.componentRef.setInput('disabled', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const buttons = fixture.nativeElement.querySelectorAll('.ui-carousel__button');
|
||||
expect(buttons.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should not navigate when disabled and navigateToPrevious is called', () => {
|
||||
fixture.componentRef.setInput('disabled', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const initialTranslate = component['currentTranslateX']();
|
||||
component.navigateToPrevious();
|
||||
|
||||
expect(component['currentTranslateX']()).toBe(initialTranslate);
|
||||
});
|
||||
|
||||
it('should not navigate when disabled and navigateToNext is called', () => {
|
||||
fixture.componentRef.setInput('disabled', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const initialTranslate = component['currentTranslateX']();
|
||||
component.navigateToNext();
|
||||
|
||||
expect(component['currentTranslateX']()).toBe(initialTranslate);
|
||||
});
|
||||
|
||||
it('should not navigate when disabled and navigateToStart is called', () => {
|
||||
fixture.componentRef.setInput('disabled', false);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Set a non-zero position first
|
||||
component['currentTranslateX'].set(-100);
|
||||
|
||||
fixture.componentRef.setInput('disabled', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.navigateToStart();
|
||||
|
||||
expect(component['currentTranslateX']()).toBe(-100);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
signal,
|
||||
viewChild,
|
||||
ElementRef,
|
||||
effect,
|
||||
AfterViewInit,
|
||||
OnDestroy,
|
||||
} from '@angular/core';
|
||||
import { IconButtonComponent } from '@isa/ui/buttons';
|
||||
import { provideIcons } from '@ng-icons/core';
|
||||
@@ -15,6 +16,7 @@ import { isaActionChevronLeft, isaActionChevronRight } from '@isa/icons';
|
||||
|
||||
@Component({
|
||||
selector: 'ui-carousel',
|
||||
exportAs: 'uiCarousel',
|
||||
imports: [IconButtonComponent],
|
||||
templateUrl: './carousel.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
@@ -23,19 +25,38 @@ import { isaActionChevronLeft, isaActionChevronRight } from '@isa/icons';
|
||||
host: {
|
||||
'[class]': '["ui-carousel", arrowAutoHideClass()]',
|
||||
'[tabindex]': '0',
|
||||
'(keydown.ArrowLeft)': 'scrollToPrevious($event)',
|
||||
'(keydown.ArrowRight)': 'scrollToNext($event)',
|
||||
'(keydown.ArrowLeft)': 'navigateToPrevious($event)',
|
||||
'(keydown.ArrowRight)': 'navigateToNext($event)',
|
||||
},
|
||||
})
|
||||
export class CarouselComponent {
|
||||
export class CarouselComponent implements AfterViewInit, OnDestroy {
|
||||
#resizeObserver = new ResizeObserver(() => {
|
||||
this.updateBounds();
|
||||
this.updateNavigationState();
|
||||
});
|
||||
|
||||
// Input signals
|
||||
gap = input<string>('1rem');
|
||||
arrowAutoHide = input<boolean>(true);
|
||||
padding = input<string>('0');
|
||||
disabled = input<boolean>(false);
|
||||
|
||||
// View child for scroll container
|
||||
scrollContainer =
|
||||
viewChild.required<ElementRef<HTMLDivElement>>('scrollContainer');
|
||||
// View child for container
|
||||
container = viewChild.required<ElementRef<HTMLDivElement>>('container');
|
||||
|
||||
// Transform position in pixels
|
||||
private currentTranslateX = signal(0);
|
||||
|
||||
// Touch/drag state
|
||||
readonly isDragging = signal(false);
|
||||
private dragStartX = 0;
|
||||
private dragStartTranslateX = 0;
|
||||
private containerWidth = 0;
|
||||
private contentWidth = 0;
|
||||
|
||||
// Computed transform style (use translate3d for hardware acceleration)
|
||||
transformX = computed(
|
||||
() => `translate3d(${this.currentTranslateX()}px, 0, 0)`,
|
||||
);
|
||||
|
||||
// Internal state signals
|
||||
canScrollLeft = signal(false);
|
||||
@@ -46,77 +67,95 @@ export class CarouselComponent {
|
||||
this.arrowAutoHide() ? 'ui-carousel--auto-hide' : '',
|
||||
);
|
||||
|
||||
showLeftArrow = computed(() => this.canScrollLeft());
|
||||
ngAfterViewInit(): void {
|
||||
const containerEl = this.container()?.nativeElement;
|
||||
this.updateBounds();
|
||||
this.updateNavigationState();
|
||||
|
||||
showRightArrow = computed(() => this.canScrollRight());
|
||||
|
||||
constructor() {
|
||||
// Update scroll state whenever the scroll container changes
|
||||
effect(() => {
|
||||
const container = this.scrollContainer()?.nativeElement;
|
||||
if (container) {
|
||||
this.updateScrollState();
|
||||
|
||||
// Add scroll event listener
|
||||
const handleScroll = () => this.updateScrollState();
|
||||
container.addEventListener('scroll', handleScroll);
|
||||
|
||||
// Add resize observer to detect content changes
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
this.updateScrollState();
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
|
||||
// Cleanup on destroy
|
||||
return () => {
|
||||
container.removeEventListener('scroll', handleScroll);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}
|
||||
return;
|
||||
containerEl.addEventListener('touchstart', this.onTouchStart, {
|
||||
passive: true,
|
||||
});
|
||||
containerEl.addEventListener('touchmove', this.onTouchMove, {
|
||||
passive: false,
|
||||
});
|
||||
containerEl.addEventListener('touchend', this.onTouchEnd);
|
||||
containerEl.addEventListener('mousedown', this.onMouseDown);
|
||||
document.addEventListener('mousemove', this.onMouseMove);
|
||||
document.addEventListener('mouseup', this.onMouseUp);
|
||||
containerEl.addEventListener('mouseleave', this.onMouseLeave);
|
||||
|
||||
this.#resizeObserver.observe(containerEl);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
const containerEl = this.container()?.nativeElement;
|
||||
containerEl.removeEventListener('touchstart', this.onTouchStart);
|
||||
containerEl.removeEventListener('touchmove', this.onTouchMove);
|
||||
containerEl.removeEventListener('touchend', this.onTouchEnd);
|
||||
containerEl.removeEventListener('mousedown', this.onMouseDown);
|
||||
document.removeEventListener('mousemove', this.onMouseMove);
|
||||
document.removeEventListener('mouseup', this.onMouseUp);
|
||||
containerEl.removeEventListener('mouseleave', this.onMouseLeave);
|
||||
this.#resizeObserver.disconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the scroll state (can scroll left/right)
|
||||
* Update container and content width bounds
|
||||
*/
|
||||
private updateScrollState(): void {
|
||||
const container = this.scrollContainer()?.nativeElement;
|
||||
if (!container) return;
|
||||
private updateBounds(): void {
|
||||
const containerEl = this.container()?.nativeElement;
|
||||
if (!containerEl) return;
|
||||
|
||||
const { scrollLeft, scrollWidth, clientWidth } = container;
|
||||
// Container width = visible viewport (parent element)
|
||||
const parentEl = containerEl.parentElement;
|
||||
if (!parentEl) return;
|
||||
this.containerWidth = parentEl.offsetWidth;
|
||||
|
||||
// Check if we can scroll left (not at the start)
|
||||
this.canScrollLeft.set(scrollLeft > 0);
|
||||
|
||||
// Check if we can scroll right (not at the end)
|
||||
// Add a small threshold (1px) to account for rounding errors
|
||||
this.canScrollRight.set(scrollLeft < scrollWidth - clientWidth - 1);
|
||||
// Content width = total width of all children (the container itself)
|
||||
this.contentWidth = containerEl.offsetWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the scroll amount based on fully visible items
|
||||
* Update navigation state (can navigate left/right)
|
||||
*/
|
||||
private calculateScrollAmount(): number {
|
||||
const container = this.scrollContainer()?.nativeElement;
|
||||
if (!container) return 0;
|
||||
private updateNavigationState(): void {
|
||||
const translate = this.currentTranslateX();
|
||||
const maxTranslate = Math.min(0, this.containerWidth - this.contentWidth);
|
||||
|
||||
const children = Array.from(container.children) as HTMLElement[];
|
||||
if (children.length === 0) return container.clientWidth;
|
||||
// Can navigate left if not at start (translateX < 0)
|
||||
this.canScrollLeft.set(translate < 0);
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const containerLeft = containerRect.left;
|
||||
const containerRight = containerRect.right;
|
||||
// Can navigate right if not at end
|
||||
this.canScrollRight.set(translate > maxTranslate);
|
||||
}
|
||||
|
||||
// Count fully visible items and get the first one
|
||||
/**
|
||||
* Calculate the navigation amount based on fully visible items
|
||||
*/
|
||||
private calculateNavigationAmount(): number {
|
||||
const containerEl = this.container()?.nativeElement;
|
||||
if (!containerEl) return 0;
|
||||
|
||||
const parentEl = containerEl.parentElement;
|
||||
if (!parentEl) return 0;
|
||||
|
||||
const children = Array.from(containerEl.children) as HTMLElement[];
|
||||
if (children.length === 0) return this.containerWidth;
|
||||
|
||||
// Use parent rect (visible viewport) not container rect (full content)
|
||||
const viewportRect = parentEl.getBoundingClientRect();
|
||||
const viewportLeft = viewportRect.left;
|
||||
const viewportRight = viewportRect.right;
|
||||
|
||||
// Count fully visible items
|
||||
let firstFullyVisibleItem: HTMLElement | null = null;
|
||||
let fullyVisibleCount = 0;
|
||||
|
||||
for (const child of children) {
|
||||
const childRect = child.getBoundingClientRect();
|
||||
const isFullyVisible =
|
||||
childRect.left >= containerLeft - 1 &&
|
||||
childRect.right <= containerRight + 1;
|
||||
childRect.left >= viewportLeft - 1 &&
|
||||
childRect.right <= viewportRight + 1;
|
||||
|
||||
if (isFullyVisible) {
|
||||
if (!firstFullyVisibleItem) {
|
||||
@@ -126,56 +165,163 @@ export class CarouselComponent {
|
||||
}
|
||||
}
|
||||
|
||||
// If we have fully visible items, use their width as scroll amount
|
||||
// Use fully visible items width if available
|
||||
if (fullyVisibleCount >= 1 && firstFullyVisibleItem) {
|
||||
// Get the next sibling to calculate the width including gap
|
||||
const nextSibling =
|
||||
firstFullyVisibleItem.nextElementSibling as HTMLElement;
|
||||
if (nextSibling) {
|
||||
const firstRect = firstFullyVisibleItem.getBoundingClientRect();
|
||||
const nextRect = nextSibling.getBoundingClientRect();
|
||||
const itemWidthWithGap = nextRect.left - firstRect.left;
|
||||
|
||||
// Scroll by the width of fully visible items
|
||||
const scrollAmount = itemWidthWithGap * fullyVisibleCount;
|
||||
return scrollAmount;
|
||||
return itemWidthWithGap * fullyVisibleCount;
|
||||
}
|
||||
|
||||
// Fallback: just use the first item's width
|
||||
return firstFullyVisibleItem.getBoundingClientRect().width;
|
||||
}
|
||||
|
||||
// Fallback: scroll by container width
|
||||
return container.clientWidth;
|
||||
// Fallback: use viewport width
|
||||
return this.containerWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to the previous set of items (left)
|
||||
* Set transform position with bounds checking
|
||||
*/
|
||||
scrollToPrevious(event?: Event): void {
|
||||
event?.preventDefault();
|
||||
const container = this.scrollContainer()?.nativeElement;
|
||||
if (!container) return;
|
||||
private setTranslateX(value: number): void {
|
||||
const maxTranslate = Math.min(0, this.containerWidth - this.contentWidth);
|
||||
const boundedValue = Math.max(maxTranslate, Math.min(0, value));
|
||||
|
||||
const scrollAmount = this.calculateScrollAmount();
|
||||
container.scrollBy({
|
||||
left: -scrollAmount,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
this.currentTranslateX.set(boundedValue);
|
||||
this.updateNavigationState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to the next set of items (right)
|
||||
* Navigate to the previous set of items
|
||||
*/
|
||||
scrollToNext(event?: Event): void {
|
||||
navigateToPrevious(event?: Event): void {
|
||||
if (this.disabled()) return;
|
||||
event?.preventDefault();
|
||||
const container = this.scrollContainer()?.nativeElement;
|
||||
if (!container) return;
|
||||
|
||||
const scrollAmount = this.calculateScrollAmount();
|
||||
container.scrollBy({
|
||||
left: scrollAmount,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
const amount = this.calculateNavigationAmount();
|
||||
this.setTranslateX(this.currentTranslateX() + amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the next set of items
|
||||
*/
|
||||
navigateToNext(event?: Event): void {
|
||||
if (this.disabled()) return;
|
||||
event?.preventDefault();
|
||||
const amount = this.calculateNavigationAmount();
|
||||
this.setTranslateX(this.currentTranslateX() - amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to start
|
||||
*/
|
||||
navigateToStart(): void {
|
||||
if (this.disabled()) return;
|
||||
this.setTranslateX(0);
|
||||
}
|
||||
|
||||
// Touch event handlers
|
||||
private onTouchStart = (e: TouchEvent): void => {
|
||||
if (this.disabled()) return;
|
||||
this.isDragging.set(true);
|
||||
this.dragStartX = e.touches[0].clientX;
|
||||
this.dragStartTranslateX = this.currentTranslateX();
|
||||
};
|
||||
|
||||
private onTouchMove = (e: TouchEvent): void => {
|
||||
if (this.disabled() || !this.isDragging()) return;
|
||||
|
||||
e.preventDefault();
|
||||
const currentX = e.touches[0].clientX;
|
||||
const deltaX = currentX - this.dragStartX;
|
||||
|
||||
// Direct DOM manipulation for smooth dragging (no signal updates)
|
||||
const maxTranslate = Math.min(0, this.containerWidth - this.contentWidth);
|
||||
const newValue = this.dragStartTranslateX + deltaX;
|
||||
const boundedValue = Math.max(maxTranslate, Math.min(0, newValue));
|
||||
|
||||
const containerEl = this.container()?.nativeElement;
|
||||
if (containerEl) {
|
||||
containerEl.style.transform = `translate3d(${boundedValue}px, 0, 0)`;
|
||||
}
|
||||
};
|
||||
|
||||
private onTouchEnd = (): void => {
|
||||
if (this.disabled()) return;
|
||||
this.isDragging.set(false);
|
||||
|
||||
// Update signal with final position
|
||||
const containerEl = this.container()?.nativeElement;
|
||||
if (containerEl) {
|
||||
const transform = containerEl.style.transform;
|
||||
const match = transform.match(/translate3d\(([-\d.]+)px/);
|
||||
if (match) {
|
||||
this.currentTranslateX.set(parseFloat(match[1]));
|
||||
this.updateNavigationState();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Mouse event handlers (for desktop drag)
|
||||
private onMouseDown = (e: MouseEvent): void => {
|
||||
if (this.disabled()) return;
|
||||
// Prevent dragging on buttons
|
||||
if ((e.target as HTMLElement).closest('button')) return;
|
||||
|
||||
this.isDragging.set(true);
|
||||
this.dragStartX = e.clientX;
|
||||
this.dragStartTranslateX = this.currentTranslateX();
|
||||
};
|
||||
|
||||
private onMouseMove = (e: MouseEvent): void => {
|
||||
if (!this.isDragging()) return;
|
||||
|
||||
const currentX = e.clientX;
|
||||
const deltaX = currentX - this.dragStartX;
|
||||
|
||||
// Direct DOM manipulation for smooth dragging (no signal updates)
|
||||
const maxTranslate = Math.min(0, this.containerWidth - this.contentWidth);
|
||||
const newValue = this.dragStartTranslateX + deltaX;
|
||||
const boundedValue = Math.max(maxTranslate, Math.min(0, newValue));
|
||||
|
||||
const containerEl = this.container()?.nativeElement;
|
||||
if (containerEl) {
|
||||
containerEl.style.transform = `translate3d(${boundedValue}px, 0, 0)`;
|
||||
}
|
||||
};
|
||||
|
||||
private onMouseUp = (): void => {
|
||||
if (this.isDragging()) {
|
||||
this.isDragging.set(false);
|
||||
|
||||
// Update signal with final position
|
||||
const containerEl = this.container()?.nativeElement;
|
||||
if (containerEl) {
|
||||
const transform = containerEl.style.transform;
|
||||
const match = transform.match(/translate3d\(([-\d.]+)px/);
|
||||
if (match) {
|
||||
this.currentTranslateX.set(parseFloat(match[1]));
|
||||
this.updateNavigationState();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private onMouseLeave = (): void => {
|
||||
if (this.isDragging()) {
|
||||
this.isDragging.set(false);
|
||||
|
||||
// Update signal with final position
|
||||
const containerEl = this.container()?.nativeElement;
|
||||
if (containerEl) {
|
||||
const transform = containerEl.style.transform;
|
||||
const match = transform.match(/translate3d\(([-\d.]+)px/);
|
||||
if (match) {
|
||||
this.currentTranslateX.set(parseFloat(match[1]));
|
||||
this.updateNavigationState();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user