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 (showLeftArrow()) {
-
- }
-
-
-
-
-
- @if (showRightArrow()) {
-
- }
-
+@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();
+ }
+ }
+ }
+ };
}