From 38de927c4e047432173f37cb6bd6e26440a3de58 Mon Sep 17 00:00:00 2001 From: Lorenz Hilpert Date: Mon, 24 Nov 2025 13:37:34 +0000 Subject: [PATCH] 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 --- .../customer-card/customer-card.component.css | 5 +- .../customer-card.component.html | 2 +- .../card-stack-container.directive.ts | 24 ++ .../card-stack-distance.directive.ts | 38 +++ .../customer-cards-carousel.component.css | 67 ++++ .../customer-cards-carousel.component.html | 15 +- .../customer-cards-carousel.component.ts | 20 +- libs/ui/carousel/src/lib/_carousel.scss | 38 +-- .../carousel/src/lib/carousel.component.html | 79 +++-- .../src/lib/carousel.component.spec.ts | 66 +++- .../ui/carousel/src/lib/carousel.component.ts | 322 +++++++++++++----- 11 files changed, 516 insertions(+), 160 deletions(-) create mode 100644 libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/card-stack-container.directive.ts create mode 100644 libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/card-stack-distance.directive.ts diff --git a/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card/customer-card.component.css b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card/customer-card.component.css index 70302ac13..8d23dd633 100644 --- a/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card/customer-card.component.css +++ b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card/customer-card.component.css @@ -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; +} diff --git a/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card/customer-card.component.html b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card/customer-card.component.html index 41c847872..cd39211fe 100644 --- a/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card/customer-card.component.html +++ b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-card/customer-card.component.html @@ -1,6 +1,6 @@
diff --git a/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/card-stack-container.directive.ts b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/card-stack-container.directive.ts new file mode 100644 index 000000000..9692aa46e --- /dev/null +++ b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/card-stack-container.directive.ts @@ -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); + + 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; + } +} diff --git a/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/card-stack-distance.directive.ts b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/card-stack-distance.directive.ts new file mode 100644 index 000000000..b63b31368 --- /dev/null +++ b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/card-stack-distance.directive.ts @@ -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); + + 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; + } +} diff --git a/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/customer-cards-carousel.component.css b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/customer-cards-carousel.component.css index 8c8927774..36436ff8d 100644 --- a/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/customer-cards-carousel.component.css +++ b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/customer-cards-carousel.component.css @@ -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; + } +} diff --git a/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/customer-cards-carousel.component.html b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/customer-cards-carousel.component.html index f9e5ba579..c0cb9ed1b 100644 --- a/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/customer-cards-carousel.component.html +++ b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/customer-cards-carousel.component.html @@ -1,12 +1,19 @@
- @for (card of sortedCards(); track card.code) { - + @for (card of sortedCards(); track card.code; let idx = $index) { + }
diff --git a/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/customer-cards-carousel.component.ts b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/customer-cards-carousel.component.ts index 33ba03407..d9a42e062 100644 --- a/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/customer-cards-carousel.component.ts +++ b/libs/crm/feature/customer-loyalty-cards/src/lib/components/customer-cards-carousel/customer-cards-carousel.component.ts @@ -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); + } } diff --git a/libs/ui/carousel/src/lib/_carousel.scss b/libs/ui/carousel/src/lib/_carousel.scss index 02ad71663..86d728f32 100644 --- a/libs/ui/carousel/src/lib/_carousel.scss +++ b/libs/ui/carousel/src/lib/_carousel.scss @@ -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 { diff --git a/libs/ui/carousel/src/lib/carousel.component.html b/libs/ui/carousel/src/lib/carousel.component.html index 21f2df0af..22d03a6e5 100644 --- a/libs/ui/carousel/src/lib/carousel.component.html +++ b/libs/ui/carousel/src/lib/carousel.component.html @@ -1,40 +1,39 @@ - +@if (!disabled() && canScrollLeft()) { + +} + + + +@if (!disabled() && canScrollRight()) { + +} diff --git a/libs/ui/carousel/src/lib/carousel.component.spec.ts b/libs/ui/carousel/src/lib/carousel.component.spec.ts index aef086641..a3b7fbf97 100644 --- a/libs/ui/carousel/src/lib/carousel.component.spec.ts +++ b/libs/ui/carousel/src/lib/carousel.component.spec.ts @@ -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; + + 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); + }); +}); diff --git a/libs/ui/carousel/src/lib/carousel.component.ts b/libs/ui/carousel/src/lib/carousel.component.ts index 68376beb9..99f7e7aba 100644 --- a/libs/ui/carousel/src/lib/carousel.component.ts +++ b/libs/ui/carousel/src/lib/carousel.component.ts @@ -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('1rem'); arrowAutoHide = input(true); - padding = input('0'); + disabled = input(false); - // View child for scroll container - scrollContainer = - viewChild.required>('scrollContainer'); + // View child for container + container = viewChild.required>('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(); + } + } + } + }; }