@ngfly/carousel
Version:
A smooth, customizable carousel component for Angular 17+ applications
1 lines • 88.5 kB
Source Map (JSON)
{"version":3,"file":"ngfly-carousel.mjs","sources":["../../../../src/lib/services/carousel.service.ts","../../../../src/lib/utils/animation.ts","../../../../src/lib/components/carousel/carousel.component.ts","../../../../src/lib/components/carousel/carousel.component.html","../../../../src/lib/directives/lazy-load.directive.ts","../../../../src/lib/carousel.module.ts","../../../../src/lib/index.ts","../../../../src/public-api.ts","../../../../src/ngfly-carousel.ts"],"sourcesContent":["import { Injectable } from '@angular/core';\nimport { BehaviorSubject } from 'rxjs';\nimport { NavButtonShape, IndicatorStyle } from '../interfaces/carousel-config.interface';\n\n/**\n * Default carousel configuration values\n */\nexport const CAROUSEL_DEFAULTS = {\n NAVIGATION_SIZE: '32px',\n CONTENT_PADDING: '10px',\n ANIMATION_DURATION: '300ms',\n ANIMATION_TIMING: 'ease',\n EMPTY_STATE_HEIGHT: '200px',\n NAVIGATION_PREV_ICON: '❮',\n NAVIGATION_NEXT_ICON: '❯',\n INDICATOR_SIZE: '10px',\n INDICATOR_SPACING: '5px',\n INDICATOR_ACTIVE_COLOR: '#333',\n INDICATOR_INACTIVE_COLOR: '#ccc',\n INDICATOR_ACTIVE_OPACITY: '1',\n INDICATOR_INACTIVE_OPACITY: '0.5',\n INDICATOR_ANIMATION_DURATION: '250ms',\n INDICATOR_ANIMATION_TIMING: 'ease',\n INDICATOR_TRANSITION: 'all 0.3s ease-in-out'\n};\n\n/**\n * Service for carousel-related functionality and state management\n */\n@Injectable({\n providedIn: 'root'\n})\nexport class CarouselService {\n // Visibility state\n private isVisibleSubject = new BehaviorSubject<boolean>(true);\n isVisible$ = this.isVisibleSubject.asObservable();\n\n /**\n * Set carousel visibility\n * @param visible Whether carousel is visible\n */\n setVisibility(visible: boolean): void {\n this.isVisibleSubject.next(visible);\n }\n\n /**\n * Get button shape styles based on the configured shape\n * @param shape Button shape type\n * @returns Style object\n */\n getButtonShapeStyles(shape?: NavButtonShape): Record<string, string> {\n const styles: Record<string, string> = {};\n \n switch (shape) {\n case 'circle':\n styles['borderRadius'] = '50%';\n break;\n case 'rounded':\n styles['borderRadius'] = '8px';\n break;\n case 'square':\n default:\n styles['borderRadius'] = '0';\n }\n \n return styles;\n }\n\n /**\n * Get indicator styles\n * @param config Indicator style configuration\n * @param isActive Whether to get active or inactive styles\n * @returns Style object\n */\n getIndicatorStyles(config?: IndicatorStyle, isActive = false): Record<string, string> {\n // Base styles for both active and inactive\n const baseStyles: Record<string, string> = {\n width: CAROUSEL_DEFAULTS.INDICATOR_SIZE,\n height: CAROUSEL_DEFAULTS.INDICATOR_SIZE,\n display: 'inline-block',\n transition: config?.transition || (config?.animation?.timing \n ? `all ${config.animation.duration || CAROUSEL_DEFAULTS.INDICATOR_ANIMATION_DURATION} ${config.animation.timing}`\n : CAROUSEL_DEFAULTS.INDICATOR_TRANSITION),\n cursor: 'pointer',\n margin: `0 ${config?.spacing || CAROUSEL_DEFAULTS.INDICATOR_SPACING}`,\n borderRadius: '50%' // Default circle shape\n };\n \n // Active/inactive specific styles\n if (isActive) {\n baseStyles['backgroundColor'] = CAROUSEL_DEFAULTS.INDICATOR_ACTIVE_COLOR;\n baseStyles['opacity'] = CAROUSEL_DEFAULTS.INDICATOR_ACTIVE_OPACITY;\n baseStyles['transform'] = 'scale(1.2)';\n \n // Add animation if enabled and not explicitly disabled\n const animEnabled = config?.animation?.enabled !== false;\n const animType = config?.animation?.type || 'pulse';\n \n if (animEnabled && animType !== 'none') {\n if (animType === 'custom' && config?.animation?.custom) {\n baseStyles['animation'] = config.animation.custom;\n } else if (animType === 'pulse') {\n baseStyles['animation'] = `indicator-pulse 1s infinite alternate`;\n }\n }\n \n // Apply custom active styles if provided (these override defaults)\n if (config?.active) {\n Object.assign(baseStyles, config.active);\n }\n } else {\n baseStyles['backgroundColor'] = CAROUSEL_DEFAULTS.INDICATOR_INACTIVE_COLOR;\n baseStyles['opacity'] = CAROUSEL_DEFAULTS.INDICATOR_INACTIVE_OPACITY;\n baseStyles['transform'] = 'scale(1)';\n \n // Apply custom inactive styles if provided (these override defaults)\n if (config?.inactive) {\n Object.assign(baseStyles, config.inactive);\n }\n }\n \n return baseStyles;\n }\n\n /**\n * Get indicator container styles based on configuration\n * @param config Indicator style configuration\n * @returns Style object for container\n */\n getIndicatorContainerStyles(config?: IndicatorStyle): Record<string, string> {\n const styles: Record<string, string> = {\n position: 'absolute',\n display: 'flex',\n justifyContent: 'center',\n alignItems: 'center',\n zIndex: '100',\n padding: '10px',\n pointerEvents: 'auto'\n };\n \n // Default position (bottom center)\n styles['bottom'] = config?.position?.bottom || '4px';\n styles['left'] = config?.position?.left || '50%';\n styles['transform'] = 'translateX(-50%)';\n \n // Override with custom positions if provided\n if (config?.position) {\n Object.keys(config.position).forEach(key => {\n const position = config.position as Record<string, string | undefined>;\n if (position[key]) {\n styles[key] = position[key] as string;\n }\n });\n \n // Handle transformations for centered positioning\n if (config.position.left === '50%' && !styles['transform']) {\n styles['transform'] = 'translateX(-50%)';\n } else if (config.position.top === '50%' && !styles['transform']) {\n styles['transform'] = 'translateY(-50%)';\n }\n }\n \n // Apply custom container styles if provided\n if (config?.container) {\n Object.assign(styles, config.container);\n }\n \n return styles;\n }\n\n /**\n * Get navigation icons based on orientation\n * @param isVertical Whether carousel is vertical\n * @param icons Custom icon configuration\n * @returns Previous and next icons\n */\n getNavigationIcons(isVertical: boolean, icons?: any): { prev: string; next: string } {\n const defaultIcons = {\n horizontal: {\n prev: CAROUSEL_DEFAULTS.NAVIGATION_PREV_ICON,\n next: CAROUSEL_DEFAULTS.NAVIGATION_NEXT_ICON\n },\n vertical: {\n prev: CAROUSEL_DEFAULTS.NAVIGATION_PREV_ICON, \n next: CAROUSEL_DEFAULTS.NAVIGATION_NEXT_ICON\n }\n };\n\n const customIcons = icons || {};\n const verticalIcons = customIcons.vertical || {};\n\n return {\n prev: isVertical\n ? (verticalIcons.prev || defaultIcons.vertical.prev)\n : (customIcons.prev || defaultIcons.horizontal.prev),\n next: isVertical\n ? (verticalIcons.next || defaultIcons.vertical.next)\n : (customIcons.next || defaultIcons.horizontal.next)\n };\n }\n\n /**\n * Parse time string to milliseconds\n * @param time Time string (e.g., '300ms', '0.5s')\n * @returns Time in milliseconds\n */\n parseTimeToMs(time: string): number {\n if (!time) return 300; // Default 300ms\n \n if (time.endsWith('ms')) {\n return parseInt(time.slice(0, -2), 10);\n }\n \n if (time.endsWith('s')) {\n return parseFloat(time.slice(0, -1)) * 1000;\n }\n \n return parseInt(time, 10);\n }\n} ","/**\n * Easing functions for animations\n */\nexport const easings = {\n linear: (t: number) => t,\n ease: (t: number) => t,\n easeInQuad: (t: number) => t * t,\n easeOutQuad: (t: number) => t * (2 - t),\n easeInOutQuad: (t: number) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t,\n easeInCubic: (t: number) => t * t * t,\n easeOutCubic: (t: number) => (--t) * t * t + 1,\n easeInOutCubic: (t: number) => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,\n};\n\n/**\n * Converts a CSS timing function to its JavaScript equivalent\n * @param timingFunction CSS timing function string\n */\nexport function getCssTimingFunction(timingFunction: string): (t: number) => number {\n switch (timingFunction) {\n case 'linear': return easings.linear;\n case 'ease-in': return easings.easeInQuad;\n case 'ease-out': return easings.easeOutQuad;\n case 'ease-in-out': return easings.easeInOutQuad;\n case 'ease': return easings.ease;\n default:\n return easings.easeInOutQuad;\n }\n}\n\n/**\n * Creates a CSS transform string for translating elements\n * \n * @param position - Translation position in pixels\n * @param isVertical - Whether to use vertical translation\n * @returns CSS transform string\n */\nexport function createTranslation(position: number, isVertical: boolean): string {\n return isVertical \n ? `translateY(-${position}px)` \n : `translateX(-${position}px)`;\n}\n\n/**\n * Parse time string to milliseconds\n * @param time Time string in the format: '300ms' or '0.3s'\n * @param defaultValue Default value in ms\n * @returns Time in milliseconds\n */\nexport function parseTimeToMs(time: string | undefined, defaultValue: number = 300): number {\n if (!time) return defaultValue;\n \n if (time.endsWith('ms')) {\n return parseInt(time, 10);\n } else if (time.endsWith('s')) {\n return parseFloat(time) * 1000;\n }\n \n return parseInt(time, 10) || defaultValue;\n}\n\n/**\n * Calculate scroll amount based on configuration\n * @param scrollSize Scroll size specification\n * @param containerSize Container width or height\n * @param scrollSizeMap Map of predefined scroll sizes\n * @param defaultPercentage Default percentage if no size specified\n * @returns Scroll amount in pixels\n */\nexport function calculateScrollAmount(\n scrollSize: string | undefined, \n containerSize: number,\n scrollSizeMap: Record<string, number>,\n defaultPercentage: number = 0.8\n): number {\n if (!scrollSize) return containerSize * defaultPercentage;\n \n // If size is a percentage\n if (scrollSize.endsWith('%')) {\n const percentage: number = parseFloat(scrollSize) / 100;\n return containerSize * percentage;\n }\n \n // If size is a predefined value\n if (scrollSizeMap[scrollSize]) {\n return scrollSizeMap[scrollSize];\n }\n \n // If size is a pixel value\n if (scrollSize.endsWith('px')) {\n return parseFloat(scrollSize);\n }\n \n return containerSize * defaultPercentage;\n}\n\n/**\n * Performs a smooth scroll animation\n * @param element Element to scroll\n * @param to Target scroll position\n * @param duration Duration in milliseconds\n * @param easing Easing function\n */\nexport function smoothScroll(\n element: HTMLElement,\n to: number,\n duration: number = 300,\n easing: (t: number) => number = easings.easeInOutQuad\n): Promise<void> {\n return new Promise(resolve => {\n const start = element.scrollLeft;\n const change = to - start;\n const startTime = performance.now();\n \n function animateScroll(currentTime: number) {\n const elapsedTime = currentTime - startTime;\n if (elapsedTime >= duration) {\n element.scrollLeft = to;\n resolve();\n return;\n }\n \n const progress = elapsedTime / duration;\n const easedProgress = easing(progress);\n element.scrollLeft = start + change * easedProgress;\n \n requestAnimationFrame(animateScroll);\n }\n \n requestAnimationFrame(animateScroll);\n });\n}\n\n/**\n * Performs a smooth vertical scroll animation\n * @param element Element to scroll\n * @param to Target scroll position\n * @param duration Duration in milliseconds\n * @param easing Easing function\n */\nexport function smoothScrollVertical(\n element: HTMLElement,\n to: number,\n duration: number = 300,\n easing: (t: number) => number = easings.easeInOutQuad\n): Promise<void> {\n return new Promise(resolve => {\n const start = element.scrollTop;\n const change = to - start;\n const startTime = performance.now();\n \n function animateScroll(currentTime: number) {\n const elapsedTime = currentTime - startTime;\n if (elapsedTime >= duration) {\n element.scrollTop = to;\n resolve();\n return;\n }\n \n const progress = elapsedTime / duration;\n const easedProgress = easing(progress);\n element.scrollTop = start + change * easedProgress;\n \n requestAnimationFrame(animateScroll);\n }\n \n requestAnimationFrame(animateScroll);\n });\n} ","import {\n Component,\n Input,\n ContentChild,\n TemplateRef,\n ElementRef,\n AfterViewInit,\n OnDestroy,\n ViewChild,\n ChangeDetectorRef,\n OnInit,\n NgZone,\n OnChanges,\n SimpleChanges,\n inject,\n Output,\n EventEmitter,\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { Subject, fromEvent } from 'rxjs';\nimport { takeUntil, debounceTime } from 'rxjs/operators';\nimport { CarouselConfig, ScrollSize } from '../../interfaces/carousel-config.interface';\nimport { CarouselService, CAROUSEL_DEFAULTS } from '../../services/carousel.service';\nimport { createTranslation } from '../../utils/animation';\n\n@Component({\n selector: 'carousel',\n standalone: true,\n imports: [CommonModule],\n templateUrl: './carousel.component.html',\n styleUrls: ['./carousel.component.scss'],\n providers: [CarouselService]\n})\nexport class CarouselComponent implements OnInit, AfterViewInit, OnDestroy, OnChanges {\n private readonly cdr: ChangeDetectorRef = inject(ChangeDetectorRef);\n private readonly ngZone: NgZone = inject(NgZone);\n private readonly carouselService: CarouselService = inject(CarouselService);\n\n @Input() slides: any[] = [];\n @Input() configs: CarouselConfig = {};\n @Input() activeIndex = 0;\n\n @Output() onPrevClick = new EventEmitter<number>();\n @Output() onNextClick = new EventEmitter<number>();\n\n /**\n * Template for rendering carousel slides & empty state\n */\n @ContentChild('carouselItem') itemTemplate!: TemplateRef<any>;\n @ContentChild('emptyState') emptyStateTemplate!: TemplateRef<any>;\n\n /**\n * Reference to the carousel track element & wrapper element\n */\n @ViewChild('track') trackElement!: ElementRef<HTMLElement>;\n @ViewChild('wrapper') wrapperElement!: ElementRef<HTMLElement>;\n\n // Private state variables\n private currentTranslate = 0;\n currentIndex = this.activeIndex || 0;\n private destroy$ = new Subject<void>();\n private autoplayInterval?: ReturnType<typeof setInterval>;\n private itemWidths: number[] = [];\n private itemHeights: number[] = [];\n private containerWidth = 0;\n private containerHeight = 0;\n private intersectionObserver: IntersectionObserver | null = null;\n private resizeObserver: ResizeObserver | null = null;\n private visibilityChangeTimeout: any = null;\n private initialized = false;\n\n // Scroll size definitions\n private readonly scrollSizeMap = {\n xs: 50,\n sm: 100,\n md: 150,\n lg: 200,\n xl: 250,\n '2xl': 300,\n '3xl': 350,\n '4xl': 400,\n '5xl': 450,\n '6xl': 500,\n '7xl': 550,\n '8xl': 600,\n '9xl': 650,\n '10xl': 700,\n full: 1,\n };\n\n showPrevButton = false;\n showNextButton = false;\n filteredItems: any[] = [];\n emptyStateText = 'No items found';\n emptyStateIcon = '📭';\n emptyStateTextColor = '#666';\n showEmptyStateIcon = true;\n\n ngOnInit(): void {\n this.filteredItems = this.slides || [];\n this.currentIndex = this.activeIndex || 0;\n \n // Initialize navigation button visibility early\n const hasMultipleItems = this.filteredItems.length > 1;\n if (hasMultipleItems) {\n this.showPrevButton = true;\n this.showNextButton = true;\n }\n \n if (this.configs.emptyState) {\n this.emptyStateText = this.configs.emptyState.text || this.emptyStateText;\n this.emptyStateIcon = this.configs.emptyState.icon || this.emptyStateIcon;\n this.emptyStateTextColor = this.configs.emptyState.textColor || this.emptyStateTextColor;\n this.showEmptyStateIcon = this.configs.emptyState.hideIcon ? false : true;\n }\n }\n\n ngOnChanges(changes: SimpleChanges): void {\n if (changes['slides']) {\n this.filteredItems = this.slides || [];\n\n if (this.initialized) {\n setTimeout(() => {\n this.updateContainerDimensions();\n this.calculateItemDimensions();\n this.checkOverflow();\n this.cdr.detectChanges();\n });\n }\n }\n\n if (changes['activeIndex']) {\n this.goToSlide(this.activeIndex);\n }\n\n if (changes['configs'] && this.initialized) {\n setTimeout(() => {\n this.updateContainerDimensions();\n this.calculateItemDimensions();\n this.checkOverflow();\n this.setupAutoplay();\n this.cdr.detectChanges();\n });\n }\n }\n\n private setupAutoplay(): void {\n this.clearAutoplayInterval();\n\n if (!this.configs.autoplay) return;\n\n const delay = this.carouselService.parseTimeToMs(this.configs.autoplayDelay || '3000ms');\n \n this.autoplayInterval = setInterval(() => {\n const track = this.trackElement?.nativeElement;\n const wrapper = this.wrapperElement?.nativeElement;\n\n // Exit if elements not ready\n if (!track || !wrapper) return;\n\n const max = this.isVertical\n ? track.offsetHeight - wrapper.offsetHeight\n : track.offsetWidth - wrapper.offsetWidth;\n\n if (this.currentTranslate >= max) {\n if (!this.configs.loop) {\n this.clearAutoplayInterval();\n return;\n }\n \n this.currentTranslate = 0;\n this.currentIndex = 0;\n } else {\n this.next();\n }\n\n this.cdr.detectChanges();\n }, delay);\n }\n\n private clearAutoplayInterval(): void {\n if (this.autoplayInterval) {\n clearInterval(this.autoplayInterval);\n this.autoplayInterval = undefined;\n }\n }\n\n ngAfterViewInit(): void {\n // Initialize early to avoid ExpressionChangedAfterItHasBeenCheckedError\n this.ngZone.runOutsideAngular(() => {\n setTimeout(() => {\n this.ngZone.run(() => {\n this.initializeCarousel();\n this.setupResizeListener();\n this.setupIntersectionObserver();\n this.setupResizeObserver();\n \n this.updateContainerDimensions();\n this.calculateItemDimensions();\n this.checkOverflow();\n this.initialized = true;\n \n // Ensure changes are applied before next change detection cycle\n this.cdr.detectChanges();\n \n // Setup autoplay after initialization to avoid change detection issues\n setTimeout(() => {\n this.setupAutoplay();\n });\n \n // Force another update after layout is complete\n setTimeout(() => {\n this.updateContainerDimensions();\n this.calculateItemDimensions();\n this.checkOverflow();\n this.cdr.detectChanges();\n }, 100);\n });\n });\n });\n }\n\n ngOnDestroy(): void {\n // Clear any interval timers\n if (this.autoplayInterval) {\n clearInterval(this.autoplayInterval);\n this.autoplayInterval = undefined;\n }\n\n // Disconnect observers\n if (this.intersectionObserver) {\n this.intersectionObserver.disconnect();\n this.intersectionObserver = null;\n }\n\n if (this.resizeObserver) {\n this.resizeObserver.disconnect();\n this.resizeObserver = null;\n }\n\n // Clear any timeouts\n if (this.visibilityChangeTimeout) {\n clearTimeout(this.visibilityChangeTimeout);\n this.visibilityChangeTimeout = null;\n }\n\n // Complete all observables\n this.destroy$.next();\n this.destroy$.complete();\n\n // Clear arrays\n this.itemWidths = [];\n this.itemHeights = [];\n this.filteredItems = [];\n }\n\n get isVertical(): boolean { return this.configs.orientation === 'vertical'; }\n\n get containerStyles(): Record<string, string> {\n const styles = this.configs.containerStyle || {};\n\n if (!('width' in styles)) { styles['width'] = this.configs.containerWidth || '100%'; }\n if (!('height' in styles)) { styles['height'] = this.configs.containerHeight || 'auto'; }\n\n return styles;\n }\n\n get trackStyles(): Record<string, string> {\n const transform = createTranslation(this.currentTranslate, this.isVertical);\n const base = { transform };\n\n return this.isVertical ? { ...base, flexDirection: 'column' } : base;\n }\n\n private initializeCarousel(): void {\n if (!this.trackElement || !this.wrapperElement) return;\n this.currentTranslate = 0;\n this.currentIndex = 0;\n\n // Initialize navigation buttons\n this.checkOverflow();\n }\n\n /**\n * Set up window resize listener\n */\n private setupResizeListener(): void {\n fromEvent(window, 'resize')\n .pipe(debounceTime(200), takeUntil(this.destroy$))\n .subscribe(() => {\n this.updateContainerDimensions();\n this.calculateItemDimensions();\n this.checkOverflow();\n });\n }\n\n /**\n * Set up intersection observer to detect when carousel becomes visible\n */\n private setupIntersectionObserver(): void {\n if ('IntersectionObserver' in window) {\n this.ngZone.runOutsideAngular(() => {\n this.intersectionObserver = new IntersectionObserver(\n (entries) => {\n const isVisible = entries[0]?.isIntersecting;\n\n if (isVisible && this.initialized) {\n this.ngZone.run(() => {\n this.visibilityChangeTimeout = setTimeout(() => {\n this.updateContainerDimensions();\n this.calculateItemDimensions();\n this.checkOverflow();\n }, 50);\n });\n }\n },\n { threshold: 0.1 }\n );\n\n if (this.wrapperElement?.nativeElement) {\n this.intersectionObserver.observe(this.wrapperElement.nativeElement);\n }\n });\n }\n }\n\n /**\n * Set up resize observer to detect when carousel's size changes\n */\n private setupResizeObserver(): void {\n if ('ResizeObserver' in window) {\n this.ngZone.runOutsideAngular(() => {\n this.resizeObserver = new ResizeObserver(() => {\n if (this.initialized) {\n this.ngZone.run(() => {\n this.updateContainerDimensions();\n this.calculateItemDimensions();\n this.checkOverflow();\n });\n }\n });\n\n if (this.wrapperElement?.nativeElement) {\n this.resizeObserver.observe(this.wrapperElement.nativeElement);\n }\n });\n }\n }\n\n private updateContainerDimensions(): void {\n if (!this.wrapperElement) return;\n this.containerWidth = this.wrapperElement.nativeElement.offsetWidth;\n this.containerHeight = this.wrapperElement.nativeElement.offsetHeight;\n }\n\n /**\n * Check if content overflows and update button visibility\n */\n private checkOverflow(): void {\n // Skip check if not yet initialized to avoid change detection errors\n if (!this.initialized && !this.trackElement?.nativeElement) return;\n\n // Always show navigation in one-item mode with more than one slide\n if (this.filteredItems.length > 1 && this.configs.singleItemMode) {\n // In one-item mode with loop, always show both buttons if we have more than one slide\n if (this.configs.loop) {\n this.showPrevButton = true;\n this.showNextButton = true;\n return;\n }\n\n // Otherwise, buttons depend on current index\n this.showPrevButton = this.currentIndex > 0;\n this.showNextButton = this.currentIndex < this.filteredItems.length - 1;\n return;\n }\n\n // First set default visibility based on showNavigation config\n if (!this.showNavigation || this.filteredItems.length <= 1) {\n this.showPrevButton = false;\n this.showNextButton = false;\n return;\n }\n\n const track = this.trackElement?.nativeElement;\n const wrapper = this.wrapperElement?.nativeElement;\n if (!track || !wrapper) return;\n\n // Skip if element has zero dimensions (may be hidden)\n if (wrapper.offsetWidth === 0 || wrapper.offsetHeight === 0) return;\n\n // Check if there's room to scroll in either direction\n // When loop is enabled, always show both buttons if there are items\n if (this.configs.loop && this.filteredItems.length > 1) {\n this.showPrevButton = true;\n this.showNextButton = true;\n } else {\n // Standard overflow checking (no loop)\n this.showPrevButton = this.currentTranslate > 0;\n\n if (this.isVertical) {\n // For vertical carousels\n const trackHeight = track.offsetHeight;\n const wrapperHeight = wrapper.offsetHeight;\n this.showNextButton = trackHeight - this.currentTranslate > wrapperHeight + 1;\n } else {\n // For horizontal carousels\n const trackWidth = track.offsetWidth;\n const wrapperWidth = wrapper.offsetWidth;\n this.showNextButton = trackWidth - this.currentTranslate > wrapperWidth + 1;\n }\n }\n\n this.cdr.detectChanges();\n }\n\n /**\n * Calculate dimensions of carousel items\n */\n private calculateItemDimensions(): void {\n if (!this.trackElement || !this.wrapperElement) return;\n\n const wrapper = this.wrapperElement.nativeElement;\n const containerHeight = wrapper.offsetHeight;\n const containerWidth = wrapper.offsetWidth;\n\n // Update stored container dimensions\n this.containerWidth = containerWidth;\n this.containerHeight = containerHeight;\n\n const items = Array.from(\n this.trackElement.nativeElement.children\n ) as HTMLElement[];\n\n if (items.length === 0) return;\n\n // Add a small delay to ensure images are loaded and measured correctly\n setTimeout(() => {\n if (this.configs.singleItemMode) {\n // In single item mode, set all items to wrapper dimensions\n if (this.isVertical) {\n // For vertical mode, each item should be full height\n this.itemHeights = items.map(() => containerHeight);\n\n this.itemWidths = items.map((item) => {\n const style = window.getComputedStyle(item);\n const width = item.offsetWidth;\n const marginLeft = parseInt(style.marginLeft || '0', 10);\n const marginRight = parseInt(style.marginRight || '0', 10);\n return width + marginLeft + marginRight;\n });\n } else {\n // For horizontal mode, each item should be full width\n this.itemWidths = items.map(() => containerWidth);\n\n this.itemHeights = items.map((item) => {\n const style = window.getComputedStyle(item);\n const height = item.offsetHeight;\n const marginTop = parseInt(style.marginTop || '0', 10);\n const marginBottom = parseInt(style.marginBottom || '0', 10);\n return height + marginTop + marginBottom;\n });\n }\n\n // Apply the calculated dimensions immediately\n this.updateTranslatePosition();\n this.cdr.detectChanges();\n return;\n }\n\n // For multi-item mode, calculate actual dimensions\n this.itemWidths = items.map((item) => {\n const style = window.getComputedStyle(item);\n const width = item.offsetWidth;\n const marginLeft = parseInt(style.marginLeft || '0', 10);\n const marginRight = parseInt(style.marginRight || '0', 10);\n return width + marginLeft + marginRight;\n });\n\n this.itemHeights = items.map((item) => {\n const style = window.getComputedStyle(item);\n const height = item.offsetHeight;\n const marginTop = parseInt(style.marginTop || '0', 10);\n const marginBottom = parseInt(style.marginBottom || '0', 10);\n return height + marginTop + marginBottom;\n });\n \n this.cdr.detectChanges();\n }, 50); // Small delay to ensure images are loaded\n }\n\n /**\n * Calculate scroll amount based on configuration\n */\n private getScrollAmount(): number {\n const wrapper = this.wrapperElement?.nativeElement;\n if (!wrapper) return 0;\n\n // For one-item-scroll mode, return item dimension\n if (this.configs.singleItemMode) {\n if (this.isVertical && this.itemHeights.length > 0) {\n // Return height of current item or default to wrapper height\n return this.currentIndex < this.itemHeights.length\n ? this.itemHeights[this.currentIndex]\n : this.itemHeights[0] || wrapper.offsetHeight;\n } else if (this.itemWidths.length > 0) {\n // Return width of current item or default to wrapper width\n return this.currentIndex < this.itemWidths.length\n ? this.itemWidths[this.currentIndex]\n : this.itemWidths[0] || wrapper.offsetWidth;\n }\n }\n\n // Otherwise, use configured scroll size\n const size = this.configs.scrollSize || 'sm';\n if (size === 'full') {\n // Full size returns container dimension\n return this.isVertical ? wrapper.offsetHeight : wrapper.offsetWidth;\n }\n\n // If size is a percentage, calculate based on container size\n if (typeof size === 'string' && size.endsWith('%')) {\n const percent = parseInt(size, 10) / 100;\n return this.isVertical\n ? wrapper.offsetHeight * percent\n : wrapper.offsetWidth * percent;\n }\n\n // Return pixel value from map or default to small\n return this.scrollSizeMap[size as ScrollSize] || this.scrollSizeMap['sm'];\n }\n\n /**\n * Navigate to previous item\n */\n previous(): void {\n if (!this.showPrevButton) return;\n\n if (this.configs.singleItemMode) {\n if (this.currentIndex > 0) {\n this.currentIndex--;\n this.updateTranslatePosition();\n } else if (this.configs.loop && this.filteredItems.length > 0) {\n // For loop mode, go to the last slide\n this.currentIndex = this.filteredItems.length - 1;\n this.updateTranslatePosition();\n }\n } else {\n const scrollAmount = this.getScrollAmount();\n this.currentTranslate = Math.max(0, this.currentTranslate - scrollAmount);\n }\n\n this.checkOverflow();\n this.onPrevClick.emit(this.currentIndex);\n }\n\n /**\n * Navigate to next item\n */\n next(): void {\n if (!this.showNextButton) return;\n\n const track = this.trackElement?.nativeElement;\n const wrapper = this.wrapperElement?.nativeElement;\n\n if (!track || !wrapper) return;\n\n if (this.configs.singleItemMode) {\n if (this.currentIndex < this.filteredItems.length - 1) {\n this.currentIndex++;\n this.updateTranslatePosition();\n } else if (this.configs.loop && this.filteredItems.length > 0) {\n // For loop mode, go back to the first slide\n this.currentIndex = 0;\n this.currentTranslate = 0;\n }\n } else {\n const scrollAmount = this.getScrollAmount();\n const maxTranslate = this.isVertical\n ? track.offsetHeight - wrapper.offsetHeight\n : track.offsetWidth - wrapper.offsetWidth;\n this.currentTranslate = Math.min(maxTranslate, this.currentTranslate + scrollAmount);\n }\n\n this.checkOverflow();\n this.onNextClick.emit(this.currentIndex);\n }\n\n private updateTranslatePosition(): void {\n if (this.currentIndex === 0) {\n this.currentTranslate = 0;\n return;\n }\n\n const gap = this.configs.itemGap ? parseInt(this.configs.itemGap.replace('px', ''), 10) : 0;\n let position = 0;\n\n // Calculate cumulative position based on item dimensions\n for (let i = 0; i < this.currentIndex; i++) {\n if (this.isVertical) {\n position += (this.itemHeights[i] || 0) + gap;\n } else {\n position += (this.itemWidths[i] || 0) + gap;\n }\n }\n\n this.currentTranslate = position;\n }\n\n getItemStyle(index: number): Record<string, string> {\n const styles: Record<string, string> = {\n flexShrink: '0',\n flexGrow: '0',\n boxSizing: 'border-box',\n overflow: 'hidden',\n borderRadius: 'inherit',\n };\n\n if (this.configs.itemWidth) {\n if (this.configs.itemWidth === '100%' && this.containerWidth > 0) {\n styles['width'] = this.containerWidth + 'px';\n styles['maxWidth'] = '100%';\n } else {\n styles['width'] = this.configs.itemWidth;\n }\n }\n\n if (this.configs.itemHeight) {\n if ((this.configs.itemHeight === '100%' || parseInt(this.configs.itemHeight, 10) === this.containerHeight) && this.containerHeight > 0) {\n styles['height'] = this.containerHeight + 'px';\n styles['minHeight'] = this.containerHeight + 'px';\n styles['maxHeight'] = '100%';\n styles['display'] = 'flex';\n styles['flexDirection'] = 'column';\n styles['alignItems'] = 'stretch';\n styles['justifyContent'] = 'stretch';\n } else {\n styles['height'] = this.configs.itemHeight;\n }\n } else if (this.isVertical && this.configs.containerHeight) {\n styles['height'] = this.containerHeight + 'px';\n styles['minHeight'] = this.containerHeight + 'px';\n }\n\n // Add margin for gap between items (except first item)\n if (!this.configs.itemGap) return styles;\n\n const marginProperty = this.isVertical ? 'marginTop' : 'marginLeft';\n styles[marginProperty] = index === 0 ? '0' : this.configs.itemGap;\n\n return styles;\n }\n\n get contentPadding(): string { return this.configs.contentPadding || CAROUSEL_DEFAULTS.CONTENT_PADDING; }\n get animationDuration(): string { return (this.configs.animationDuration || CAROUSEL_DEFAULTS.ANIMATION_DURATION); }\n get animationTiming(): string { return this.configs.animationTiming || CAROUSEL_DEFAULTS.ANIMATION_TIMING; }\n get showNavigation(): boolean { return this.configs.showNavigation ?? true; }\n\n getEmptyStateContainerStyle(): Record<string, string> {\n return {\n width: '100%',\n boxSizing: 'border-box',\n borderRadius: 'inherit',\n backgroundColor: this.configs.emptyState?.backgroundColor || 'transparent',\n };\n }\n\n hasItems(): boolean { return this.filteredItems?.length > 0; }\n getNavControlsClass(): string { return 'carousel__nav-controls'; }\n\n getNavControlsStyle(): Record<string, string> {\n const styles: Record<string, string> = {\n position: 'absolute',\n top: '0',\n left: '0',\n width: '100%',\n height: '100%',\n pointerEvents: 'none',\n };\n \n // Apply custom z-index if specified in config\n if (this.configs.navigationStyle?.zIndex) {\n styles['--carousel-z-index'] = this.configs.navigationStyle.zIndex;\n }\n \n return styles;\n }\n\n get prevIcon(): string {\n const icons = this.carouselService.getNavigationIcons(\n this.isVertical,\n this.configs.navigationStyle?.icons\n );\n return icons.prev;\n }\n\n get nextIcon(): string {\n const icons = this.carouselService.getNavigationIcons(\n this.isVertical,\n this.configs.navigationStyle?.icons\n );\n return icons.next;\n }\n\n getIconStyles(isNext: boolean): Record<string, string> {\n const color = isNext\n ? (this.configs.navigationStyle?.nextButton as any)?.color || '#666'\n : (this.configs.navigationStyle?.prevButton as any)?.color || '#666';\n\n return { color };\n }\n\n /**\n * Calculate position styles for navigation buttons\n */\n private getButtonPositionStyle(button: 'prev' | 'next'): Record<string, any> {\n const navConfig = this.configs.navigationStyle;\n const buttonStyle = button === 'prev' ? navConfig?.prevButton || {} : navConfig?.nextButton || {};\n const style: Record<string, any> = {\n position: 'absolute',\n pointerEvents: 'auto'\n };\n\n // Handle positioning properties\n ['top', 'bottom', 'left', 'right'].forEach(prop => {\n const value = (buttonStyle as any)[prop];\n if (value !== undefined) {\n style[prop] = value === 0 || value === '0' ? '0px' : value;\n }\n });\n\n // Handle transforms based on positioning\n const transformMap = {\n 'left=50%': 'translateX(-50%)',\n 'right=50%': 'translateX(50%)', \n 'top=50%': 'translateY(-50%)',\n 'bottom=50%': 'translateY(50%)'\n };\n\n // Find matching transform\n for (const [position, transform] of Object.entries(transformMap)) {\n const [prop, value] = position.split('=');\n if ((buttonStyle as any)[prop] === value) {\n style['transform'] = (buttonStyle as any).transform \n ? `${(buttonStyle as any).transform} ${transform}`\n : transform;\n break;\n }\n }\n\n // Use custom transform if specified\n if (!style['transform'] && (buttonStyle as any).transform) {\n style['transform'] = (buttonStyle as any).transform;\n }\n\n // Apply default positioning if none specified\n const hasPosition = ['top', 'bottom', 'left', 'right'].some(prop => \n (buttonStyle as any)[prop] !== undefined\n );\n\n if (!hasPosition) {\n if (this.isVertical) {\n style['left'] = '50%';\n style['transform'] = 'translateX(-50%)';\n style[button === 'prev' ? 'top' : 'bottom'] = '0px';\n } else {\n style['top'] = '50%';\n style['transform'] = 'translateY(-50%)';\n style[button === 'prev' ? 'left' : 'right'] = '0px';\n }\n }\n\n return style;\n }\n\n /**\n * Get full styles for navigation buttons\n * @param buttonType - Type of button ('prev' or 'next')\n */\n private getButtonFullStyles(buttonType: 'prev' | 'next'): Record<string, any> {\n // Get base styles including shape styles\n const styles = {\n ...this.carouselService.getButtonShapeStyles(\n this.configs.navigationStyle?.buttonShape\n ),\n };\n\n // Apply custom styles from config\n const buttonConfig = this.configs.navigationStyle?.[`${buttonType}Button`];\n if (buttonConfig) {\n // Apply position styles first\n const positionStyles = this.getButtonPositionStyle(buttonType);\n Object.assign(styles, positionStyles);\n\n // Apply custom button styles but preserve shape styles\n const buttonShape = this.configs.navigationStyle?.buttonShape;\n if (buttonShape) {\n const { borderRadius, ...otherButtonConfig } = buttonConfig as Record<string, any>;\n Object.assign(styles, otherButtonConfig);\n } else {\n Object.assign(styles, buttonConfig);\n }\n \n // Set the CSS variable for z-index if specified in button config\n if ((buttonConfig as any).zIndex) {\n styles['--carousel-z-index'] = (buttonConfig as any).zIndex;\n }\n // If no button-specific z-index, but global navigation z-index exists, use that\n else if (this.configs.navigationStyle?.zIndex) {\n styles['--carousel-z-index'] = this.configs.navigationStyle.zIndex;\n }\n }\n\n return styles;\n }\n\n getPrevButtonFullStyles(): Record<string, any> { return this.getButtonFullStyles('prev'); }\n getNextButtonFullStyles(): Record<string, any> { return this.getButtonFullStyles('next'); }\n\n getPrevIndex(activeIndex: number): number {\n const itemsCount = this.filteredItems?.length || 0;\n if (itemsCount === 0) return activeIndex;\n\n return activeIndex > 0\n ? activeIndex - 1\n : this.configs.loop\n ? itemsCount - 1\n : activeIndex;\n }\n\n goToPrevSlide(): void {\n const prevIndex = this.getPrevIndex(this.currentIndex);\n if (prevIndex !== this.currentIndex) {\n this.currentIndex = prevIndex;\n this.updateTranslatePosition();\n this.checkOverflow();\n }\n }\n\n getIndicatorContainerStyles(): Record<string, string> { return this.carouselService.getIndicatorContainerStyles(this.configs.indicatorStyle); }\n\n /**\n * Get styles for an individual indicator\n * @param index Index of the indicator\n */\n getIndicatorItemStyles(index: number): Record<string, string> {\n const isActive = index === this.currentIndex;\n return this.carouselService.getIndicatorStyles(this.configs.indicatorStyle, isActive);\n }\n\n /**\n * Navigate to a specific slide when indicator is clicked\n * @param index Target slide index\n */\n goToSlide(index: number): void {\n if (index === this.currentIndex || index < 0 || index >= this.filteredItems.length) return;\n \n this.currentIndex = index;\n\n if (this.configs.singleItemMode) {\n this.updateTranslatePosition();\n this.checkOverflow();\n return;\n }\n\n const track = this.trackElement?.nativeElement;\n const wrapper = this.wrapperElement?.nativeElement;\n \n if (!track || !wrapper) return;\n \n const gap = parseInt(this.configs.itemGap?.replace('px', '') || '0', 10);\n const dimensions = this.isVertical ? this.itemHeights : this.itemWidths;\n \n // Calculate cumulative position up to target index\n const position = dimensions\n .slice(0, index)\n .reduce((sum, size) => sum + (size || 0) + gap, 0);\n \n const maxTranslate = this.isVertical\n ? track.offsetHeight - wrapper.offsetHeight \n : track.offsetWidth - wrapper.offsetWidth;\n \n this.currentTranslate = Math.min(maxTranslate, Math.max(0, position));\n this.checkOverflow();\n }\n\n /**\n * Whether to show indicators\n */\n get showIndicators(): boolean { return this.configs.showIndicators ?? false; }\n}\n","<div class=\"carousel\" \n [class.carousel--vertical]=\"isVertical\" \n [ngClass]=\"configs.containerClass\" \n [ngStyle]=\"containerStyles\"\n [style.height]=\"configs.containerHeight || 'auto'\"\n [style.width]=\"configs.containerWidth || 'auto'\">\n <div\n #wrapper\n [style.--content-padding]=\"contentPadding\"\n class=\"carousel__wrapper\">\n <div\n #track\n [ngStyle]=\"trackStyles\"\n [class.carousel__track--vertical]=\"isVertical\"\n [style.--animation-duration]=\"animationDuration\"\n [style.--animation-timing]=\"animationTiming\"\n class=\"carousel__track\">\n <ng-container *ngIf=\"hasItems(); else emptyState\">\n <ng-container *ngFor=\"let slide of filteredItems; let i = index\">\n <div class=\"carousel__item\" \n [ngClass]=\"configs.itemClass\" \n [ngStyle]=\"getItemStyle(i)\" \n [style.--item-width]=\"configs.itemWidth || 'auto'\"\n [style.--item-height]=\"configs.itemHeight || 'auto'\">\n <ng-container *ngIf=\"itemTemplate; else defaultTemplate\">\n <ng-container *ngTemplateOutlet=\"itemTemplate; context: { $implicit: slide, index: i }\"></ng-container>\n </ng-container>\n <ng-template #defaultTemplate>\n <div class=\"carousel__item-default\">\n {{ slide }}\n </div>\n </ng-template>\n </div>\n </ng-container>\n </ng-container>\n \n <ng-template #emptyState>\n <div class=\"carousel__empty-container\" [ngStyle]=\"getEmptyStateContainerStyle()\">\n <ng-container *ngIf=\"emptyStateTemplate; else defaultEmptyState\">\n <ng-container *ngTemplateOutlet=\"emptyStateTemplate; context: { $implicit: emptyStateText }\"></ng-container>\n </ng-container>\n <ng-template #defaultEmptyState>\n <div class=\"carousel__empty-state\" [style.color]=\"emptyStateTextColor\">\n <div *ngIf=\"showEmptyStateIcon\" class=\"carousel__empty-icon\">{{emptyStateIcon}}</div>\n <div class=\"carousel__empty-text\">{{emptyStateText}}</div>\n </div>\n </ng-template>\n </div>\n </ng-template>\n </div>\n </div>\n\n <div *ngIf=\"showNavigation && hasItems()\" [ngClass]=\"getNavControlsClass()\" [ngStyle]=\"getNavControlsStyle()\"> \n <button \n [class.carousel__nav-button--disabled]=\"!showPrevButton\" \n [disabled]=\"!showPrevButton\" \n [ngStyle]=\"getPrevButtonFullStyles()\" \n (click)=\"previous()\" \n style=\"pointer-events:auto;\"\n class=\"carousel__nav-button\">\n <span class=\"carousel__nav-icon\" [ngStyle]=\"getIconStyles(false)\">{{ prevIcon }}</span>\n </button>\n \n <button\n [class.carousel__nav-button--disabled]=\"!showNextButton\"\n [disabled]=\"!showNextButton\"\n [ngStyle]=\"getNextButtonFullStyles()\"\n (click)=\"next()\"\n style=\"pointer-events:auto;\"\n class=\"carousel__nav-button\">\n <span class=\"carousel__nav-icon\" [ngStyle]=\"getIconStyles(true)\">{{ nextIcon }}</span>\n </button>\n </div>\n\n <!-- Carousel indicators -->\n <div\n *ngIf=\"showIndicators && hasItems() && filteredItems.length > 1\" \n class=\"carousel__indicators\" \n [ngStyle]=\"getIndicatorContainerStyles()\">\n <div\n *ngFor=\"let slide of filteredItems; let i = index\"\n class=\"carousel__indicator\"\n [class.carousel__indicator--active]=\"i === currentIndex\"\n [ngStyle]=\"getIndicatorItemStyles(i)\"\n (click)=\"goToSlide(i)\">\n </div>\n </div>\n</div>\n","import { Directive, ElementRef, Input, OnDestroy, OnInit, Renderer2 } from '@angular/core';\n\n/**\n * LazyLoadDirective\n * \n * A directive for lazy loading images in a carousel component.\n * Uses Intersection Observer API to detect when images enter the viewport.\n */\n@Directive({\n selector: '[carouselLazyLoad]',\n standalone: true\n})\nexport class LazyLoadDirective implements OnInit, OnDestroy {\n @Input() carouselLazyLoad: string = '';\n @Input() errorImage: string = 'assets/images/no-image.png';\n \n private observer: IntersectionObserver | undefined;\n private isLoaded = false;\n \n constructor(\n private el: ElementRef,\n private renderer: Renderer2\n ) {}\n \n /**\n * Initialize the directive and set up the Intersection Observer\n */\n ngOnInit(): void {\n // Skip if already loaded or no image URL provided\n if (this.isLoaded || !this.carouselLazyLoad) {\n return;\n }\n \n // Check if IntersectionObserver is supported\n if ('IntersectionObserver' in window) {\n this.observer = new IntersectionObserver(entries => {\n entries.forEach(entry => {\n if (entry.isIntersecting) {\n this.loadImage();\n this.observer?.unobserve(this.el.nativeElement);\n }\n });\n });\n \n this.observer.observe(this.el.nativeElement);\n } else {\n // Fallback for browsers that don't support IntersectionObserver\n this.loadImage();\n }\n }\n \n /**\n * Clean up the observer when directive is destroyed\n */\n ngOnDestroy(): void {\n // Clean up the observer when directive is destroyed\n if (this.observer) {\n this.observer.disconnect();\n this.observer = undefined;\n }\n }\n \n /**\n * Load the image by setting the src attribute\n */\n private loadImage(): void {\n this.isLoaded = true;\n \n const element = this.el.nativeElement;\n const isImgElement = element.tagName === 'IMG';\n \n if (isImgElement) {\n // Handle <img> elements\n this.renderer.setAttribute(element, 'src', this.carouselLazyLoad);\n \n // Handle errors\n const errorHandler = () => {\n this.renderer.setAttribute(element, 'src', this.errorImage);\n const unlistenFn = this.renderer.listen(element, 'error', errorHandler);\n unlistenFn();\n };\n \n this.renderer.listen(element, 'error', errorHandler);\n } else {\n // Handle non-image elements with background image\n this.renderer.setStyle(\n element,\n 'background-image',\n `url('${this.carouselLazyLoad}')`\n );\n }\n }\n}","import { NgModule } from '@angular/core';\nimport { CarouselComponent } from './components/carousel/carousel.component';\nimport { LazyLoadDirective } from './directives/lazy-load.directive';\n\n/**\n * Carousel Module\n * \n * This module provides a smooth, customizable carousel component for Angular applications.\n * While the components are standalone, this module is provided for backward compatibility.\n */\n@NgModule({\n imports: [\n CarouselComponent,\n LazyLoadDirective\n ],\n exports: [\n CarouselComponent,\n LazyLoadDirective\n ]\n})\nexport class CarouselModule {}\n","/**\n * Main export file for the NSC library\n *\n * Available inputs:\n * - slides: The collection of items to display in the carousel\n * - configs: The configuration object for the carousel\n */\n\n// Module\nexport * from './carousel.module';\n\n// Components\nexport * from './components/carousel/carousel.component';\n\n// Interfaces\nexport * from './interfaces/carousel-config.interface';\n\n// Services\nexport * from './services/carousel.service';\n\n// Directives\nexport * from './directives/lazy-load.directive';\n\n// Utilities\nexport * from './utils/animation';","/*\n * Public API Surface of nsc\n */\n\nexport * from './lib/index';\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './public-api';\n"],"names":[],"mappings":";;;;;;;AAIA;;AAEG;AACU,MAAA,iBAAiB,GAAG;AAC/B,IAAA,eAAe,EAAE,MAAM;AACvB,IAAA,eAAe,EAAE,MAAM;AACvB,IAAA,kBAAkB,EAAE,OAAO;AAC3B,IAAA,gBAAgB,EAAE,MAAM;AACxB,IAAA