@ngfly/carousel
Version:
A smooth, customizable carousel component for Angular 17+ applications
1,103 lines (1,100 loc) • 71.3 kB
JavaScript
import * as i0 from '@angular/core';
import { Injectable, inject, ChangeDetectorRef, NgZone, EventEmitter, Component, Input, Output, ContentChild, ViewChild, Directive, NgModule } from '@angular/core';
import { __rest } from 'tslib';
import * as i1 from '@angular/common';
import { CommonModule } from '@angular/common';
import { BehaviorSubject, Subject, fromEvent } from 'rxjs';
import { debounceTime, takeUntil } from 'rxjs/operators';
/**
* Default carousel configuration values
*/
const CAROUSEL_DEFAULTS = {
NAVIGATION_SIZE: '32px',
CONTENT_PADDING: '10px',
ANIMATION_DURATION: '300ms',
ANIMATION_TIMING: 'ease',
EMPTY_STATE_HEIGHT: '200px',
NAVIGATION_PREV_ICON: '❮',
NAVIGATION_NEXT_ICON: '❯',
INDICATOR_SIZE: '10px',
INDICATOR_SPACING: '5px',
INDICATOR_ACTIVE_COLOR: '#333',
INDICATOR_INACTIVE_COLOR: '#ccc',
INDICATOR_ACTIVE_OPACITY: '1',
INDICATOR_INACTIVE_OPACITY: '0.5',
INDICATOR_ANIMATION_DURATION: '250ms',
INDICATOR_ANIMATION_TIMING: 'ease',
INDICATOR_TRANSITION: 'all 0.3s ease-in-out'
};
/**
* Service for carousel-related functionality and state management
*/
class CarouselService {
constructor() {
// Visibility state
this.isVisibleSubject = new BehaviorSubject(true);
this.isVisible$ = this.isVisibleSubject.asObservable();
}
/**
* Set carousel visibility
* @param visible Whether carousel is visible
*/
setVisibility(visible) {
this.isVisibleSubject.next(visible);
}
/**
* Get button shape styles based on the configured shape
* @param shape Button shape type
* @returns Style object
*/
getButtonShapeStyles(shape) {
const styles = {};
switch (shape) {
case 'circle':
styles['borderRadius'] = '50%';
break;
case 'rounded':
styles['borderRadius'] = '8px';
break;
case 'square':
default:
styles['borderRadius'] = '0';
}
return styles;
}
/**
* Get indicator styles
* @param config Indicator style configuration
* @param isActive Whether to get active or inactive styles
* @returns Style object
*/
getIndicatorStyles(config, isActive = false) {
var _a, _b, _c, _d;
// Base styles for both active and inactive
const baseStyles = {
width: CAROUSEL_DEFAULTS.INDICATOR_SIZE,
height: CAROUSEL_DEFAULTS.INDICATOR_SIZE,
display: 'inline-block',
transition: (config === null || config === void 0 ? void 0 : config.transition) || (((_a = config === null || config === void 0 ? void 0 : config.animation) === null || _a === void 0 ? void 0 : _a.timing)
? `all ${config.animation.duration || CAROUSEL_DEFAULTS.INDICATOR_ANIMATION_DURATION} ${config.animation.timing}`
: CAROUSEL_DEFAULTS.INDICATOR_TRANSITION),
cursor: 'pointer',
margin: `0 ${(config === null || config === void 0 ? void 0 : config.spacing) || CAROUSEL_DEFAULTS.INDICATOR_SPACING}`,
borderRadius: '50%' // Default circle shape
};
// Active/inactive specific styles
if (isActive) {
baseStyles['backgroundColor'] = CAROUSEL_DEFAULTS.INDICATOR_ACTIVE_COLOR;
baseStyles['opacity'] = CAROUSEL_DEFAULTS.INDICATOR_ACTIVE_OPACITY;
baseStyles['transform'] = 'scale(1.2)';
// Add animation if enabled and not explicitly disabled
const animEnabled = ((_b = config === null || config === void 0 ? void 0 : config.animation) === null || _b === void 0 ? void 0 : _b.enabled) !== false;
const animType = ((_c = config === null || config === void 0 ? void 0 : config.animation) === null || _c === void 0 ? void 0 : _c.type) || 'pulse';
if (animEnabled && animType !== 'none') {
if (animType === 'custom' && ((_d = config === null || config === void 0 ? void 0 : config.animation) === null || _d === void 0 ? void 0 : _d.custom)) {
baseStyles['animation'] = config.animation.custom;
}
else if (animType === 'pulse') {
baseStyles['animation'] = `indicator-pulse 1s infinite alternate`;
}
}
// Apply custom active styles if provided (these override defaults)
if (config === null || config === void 0 ? void 0 : config.active) {
Object.assign(baseStyles, config.active);
}
}
else {
baseStyles['backgroundColor'] = CAROUSEL_DEFAULTS.INDICATOR_INACTIVE_COLOR;
baseStyles['opacity'] = CAROUSEL_DEFAULTS.INDICATOR_INACTIVE_OPACITY;
baseStyles['transform'] = 'scale(1)';
// Apply custom inactive styles if provided (these override defaults)
if (config === null || config === void 0 ? void 0 : config.inactive) {
Object.assign(baseStyles, config.inactive);
}
}
return baseStyles;
}
/**
* Get indicator container styles based on configuration
* @param config Indicator style configuration
* @returns Style object for container
*/
getIndicatorContainerStyles(config) {
var _a, _b;
const styles = {
position: 'absolute',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: '100',
padding: '10px',
pointerEvents: 'auto'
};
// Default position (bottom center)
styles['bottom'] = ((_a = config === null || config === void 0 ? void 0 : config.position) === null || _a === void 0 ? void 0 : _a.bottom) || '4px';
styles['left'] = ((_b = config === null || config === void 0 ? void 0 : config.position) === null || _b === void 0 ? void 0 : _b.left) || '50%';
styles['transform'] = 'translateX(-50%)';
// Override with custom positions if provided
if (config === null || config === void 0 ? void 0 : config.position) {
Object.keys(config.position).forEach(key => {
const position = config.position;
if (position[key]) {
styles[key] = position[key];
}
});
// Handle transformations for centered positioning
if (config.position.left === '50%' && !styles['transform']) {
styles['transform'] = 'translateX(-50%)';
}
else if (config.position.top === '50%' && !styles['transform']) {
styles['transform'] = 'translateY(-50%)';
}
}
// Apply custom container styles if provided
if (config === null || config === void 0 ? void 0 : config.container) {
Object.assign(styles, config.container);
}
return styles;
}
/**
* Get navigation icons based on orientation
* @param isVertical Whether carousel is vertical
* @param icons Custom icon configuration
* @returns Previous and next icons
*/
getNavigationIcons(isVertical, icons) {
const defaultIcons = {
horizontal: {
prev: CAROUSEL_DEFAULTS.NAVIGATION_PREV_ICON,
next: CAROUSEL_DEFAULTS.NAVIGATION_NEXT_ICON
},
vertical: {
prev: CAROUSEL_DEFAULTS.NAVIGATION_PREV_ICON,
next: CAROUSEL_DEFAULTS.NAVIGATION_NEXT_ICON
}
};
const customIcons = icons || {};
const verticalIcons = customIcons.vertical || {};
return {
prev: isVertical
? (verticalIcons.prev || defaultIcons.vertical.prev)
: (customIcons.prev || defaultIcons.horizontal.prev),
next: isVertical
? (verticalIcons.next || defaultIcons.vertical.next)
: (customIcons.next || defaultIcons.horizontal.next)
};
}
/**
* Parse time string to milliseconds
* @param time Time string (e.g., '300ms', '0.5s')
* @returns Time in milliseconds
*/
parseTimeToMs(time) {
if (!time)
return 300; // Default 300ms
if (time.endsWith('ms')) {
return parseInt(time.slice(0, -2), 10);
}
if (time.endsWith('s')) {
return parseFloat(time.slice(0, -1)) * 1000;
}
return parseInt(time, 10);
}
}
CarouselService.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "14.3.0", ngImport: i0, type: CarouselService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
CarouselService.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "14.3.0", ngImport: i0, type: CarouselService, providedIn: 'root' });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "14.3.0", ngImport: i0, type: CarouselService, decorators: [{
type: Injectable,
args: [{
providedIn: 'root'
}]
}] });
/**
* Easing functions for animations
*/
const easings = {
linear: (t) => t,
ease: (t) => t,
easeInQuad: (t) => t * t,
easeOutQuad: (t) => t * (2 - t),
easeInOutQuad: (t) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t,
easeInCubic: (t) => t * t * t,
easeOutCubic: (t) => (--t) * t * t + 1,
easeInOutCubic: (t) => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,
};
/**
* Converts a CSS timing function to its JavaScript equivalent
* @param timingFunction CSS timing function string
*/
function getCssTimingFunction(timingFunction) {
switch (timingFunction) {
case 'linear': return easings.linear;
case 'ease-in': return easings.easeInQuad;
case 'ease-out': return easings.easeOutQuad;
case 'ease-in-out': return easings.easeInOutQuad;
case 'ease': return easings.ease;
default:
return easings.easeInOutQuad;
}
}
/**
* Creates a CSS transform string for translating elements
*
* @param position - Translation position in pixels
* @param isVertical - Whether to use vertical translation
* @returns CSS transform string
*/
function createTranslation(position, isVertical) {
return isVertical
? `translateY(-${position}px)`
: `translateX(-${position}px)`;
}
/**
* Parse time string to milliseconds
* @param time Time string in the format: '300ms' or '0.3s'
* @param defaultValue Default value in ms
* @returns Time in milliseconds
*/
function parseTimeToMs(time, defaultValue = 300) {
if (!time)
return defaultValue;
if (time.endsWith('ms')) {
return parseInt(time, 10);
}
else if (time.endsWith('s')) {
return parseFloat(time) * 1000;
}
return parseInt(time, 10) || defaultValue;
}
/**
* Calculate scroll amount based on configuration
* @param scrollSize Scroll size specification
* @param containerSize Container width or height
* @param scrollSizeMap Map of predefined scroll sizes
* @param defaultPercentage Default percentage if no size specified
* @returns Scroll amount in pixels
*/
function calculateScrollAmount(scrollSize, containerSize, scrollSizeMap, defaultPercentage = 0.8) {
if (!scrollSize)
return containerSize * defaultPercentage;
// If size is a percentage
if (scrollSize.endsWith('%')) {
const percentage = parseFloat(scrollSize) / 100;
return containerSize * percentage;
}
// If size is a predefined value
if (scrollSizeMap[scrollSize]) {
return scrollSizeMap[scrollSize];
}
// If size is a pixel value
if (scrollSize.endsWith('px')) {
return parseFloat(scrollSize);
}
return containerSize * defaultPercentage;
}
/**
* Performs a smooth scroll animation
* @param element Element to scroll
* @param to Target scroll position
* @param duration Duration in milliseconds
* @param easing Easing function
*/
function smoothScroll(element, to, duration = 300, easing = easings.easeInOutQuad) {
return new Promise(resolve => {
const start = element.scrollLeft;
const change = to - start;
const startTime = performance.now();
function animateScroll(currentTime) {
const elapsedTime = currentTime - startTime;
if (elapsedTime >= duration) {
element.scrollLeft = to;
resolve();
return;
}
const progress = elapsedTime / duration;
const easedProgress = easing(progress);
element.scrollLeft = start + change * easedProgress;
requestAnimationFrame(animateScroll);
}
requestAnimationFrame(animateScroll);
});
}
/**
* Performs a smooth vertical scroll animation
* @param element Element to scroll
* @param to Target scroll position
* @param duration Duration in milliseconds
* @param easing Easing function
*/
function smoothScrollVertical(element, to, duration = 300, easing = easings.easeInOutQuad) {
return new Promise(resolve => {
const start = element.scrollTop;
const change = to - start;
const startTime = performance.now();
function animateScroll(currentTime) {
const elapsedTime = currentTime - startTime;
if (elapsedTime >= duration) {
element.scrollTop = to;
resolve();
return;
}
const progress = elapsedTime / duration;
const easedProgress = easing(progress);
element.scrollTop = start + change * easedProgress;
requestAnimationFrame(animateScroll);
}
requestAnimationFrame(animateScroll);
});
}
class CarouselComponent {
constructor() {
this.cdr = inject(ChangeDetectorRef);
this.ngZone = inject(NgZone);
this.carouselService = inject(CarouselService);
this.slides = [];
this.configs = {};
this.activeIndex = 0;
this.onPrevClick = new EventEmitter();
this.onNextClick = new EventEmitter();
// Private state variables
this.currentTranslate = 0;
this.currentIndex = this.activeIndex || 0;
this.destroy$ = new Subject();
this.itemWidths = [];
this.itemHeights = [];
this.containerWidth = 0;
this.containerHeight = 0;
this.intersectionObserver = null;
this.resizeObserver = null;
this.visibilityChangeTimeout = null;
this.initialized = false;
// Scroll size definitions
this.scrollSizeMap = {
xs: 50,
sm: 100,
md: 150,
lg: 200,
xl: 250,
'2xl': 300,
'3xl': 350,
'4xl': 400,
'5xl': 450,
'6xl': 500,
'7xl': 550,
'8xl': 600,
'9xl': 650,
'10xl': 700,
full: 1,
};
this.showPrevButton = false;
this.showNextButton = false;
this.filteredItems = [];
this.emptyStateText = 'No items found';
this.emptyStateIcon = '📭';
this.emptyStateTextColor = '#666';
this.showEmptyStateIcon = true;
}
ngOnInit() {
this.filteredItems = this.slides || [];
this.currentIndex = this.activeIndex || 0;
// Initialize navigation button visibility early
const hasMultipleItems = this.filteredItems.length > 1;
if (hasMultipleItems) {
this.showPrevButton = true;
this.showNextButton = true;
}
if (this.configs.emptyState) {
this.emptyStateText = this.configs.emptyState.text || this.emptyStateText;
this.emptyStateIcon = this.configs.emptyState.icon || this.emptyStateIcon;
this.emptyStateTextColor = this.configs.emptyState.textColor || this.emptyStateTextColor;
this.showEmptyStateIcon = this.configs.emptyState.hideIcon ? false : true;
}
}
ngOnChanges(changes) {
if (changes['slides']) {
this.filteredItems = this.slides || [];
if (this.initialized) {
setTimeout(() => {
this.updateContainerDimensions();
this.calculateItemDimensions();
this.checkOverflow();
this.cdr.detectChanges();
});
}
}
if (changes['activeIndex']) {
this.goToSlide(this.activeIndex);
}
if (changes['configs'] && this.initialized) {
setTimeout(() => {
this.updateContainerDimensions();
this.calculateItemDimensions();
this.checkOverflow();
this.setupAutoplay();
this.cdr.detectChanges();
});
}
}
setupAutoplay() {
this.clearAutoplayInterval();
if (!this.configs.autoplay)
return;
const delay = this.carouselService.parseTimeToMs(this.configs.autoplayDelay || '3000ms');
this.autoplayInterval = setInterval(() => {
var _a, _b;
const track = (_a = this.trackElement) === null || _a === void 0 ? void 0 : _a.nativeElement;
const wrapper = (_b = this.wrapperElement) === null || _b === void 0 ? void 0 : _b.nativeElement;
// Exit if elements not ready
if (!track || !wrapper)
return;
const max = this.isVertical
? track.offsetHeight - wrapper.offsetHeight
: track.offsetWidth - wrapper.offsetWidth;
if (this.currentTranslate >= max) {
if (!this.configs.loop) {
this.clearAutoplayInterval();
return;
}
this.currentTranslate = 0;
this.currentIndex = 0;
}
else {
this.next();
}
this.cdr.detectChanges();
}, delay);
}
clearAutoplayInterval() {
if (this.autoplayInterval) {
clearInterval(this.autoplayInterval);
this.autoplayInterval = undefined;
}
}
ngAfterViewInit() {
// Initialize early to avoid ExpressionChangedAfterItHasBeenCheckedError
this.ngZone.runOutsideAngular(() => {
setTimeout(() => {
this.ngZone.run(() => {
this.initializeCarousel();
this.setupResizeListener();
this.setupIntersectionObserver();
this.setupResizeObserver();
this.updateContainerDimensions();
this.calculateItemDimensions();
this.checkOverflow();
this.initialized = true;
// Ensure changes are applied before next change detection cycle
this.cdr.detectChanges();
// Setup autoplay after initialization to avoid change detection issues
setTimeout(() => {
this.setupAutoplay();
});
// Force another update after layout is complete
setTimeout(() => {
this.updateContainerDimensions();
this.calculateItemDimensions();
this.checkOverflow();
this.cdr.detectChanges();
}, 100);
});
});
});
}
ngOnDestroy() {
// Clear any interval timers
if (this.autoplayInterval) {
clearInterval(this.autoplayInterval);
this.autoplayInterval = undefined;
}
// Disconnect observers
if (this.intersectionObserver) {
this.intersectionObserver.disconnect();
this.intersectionObserver = null;
}
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
// Clear any timeouts
if (this.visibilityChangeTimeout) {
clearTimeout(this.visibilityChangeTimeout);
this.visibilityChangeTimeout = null;
}
// Complete all observables
this.destroy$.next();
this.destroy$.complete();
// Clear arrays
this.itemWidths = [];
this.itemHeights = [];
this.filteredItems = [];
}
get isVertical() { return this.configs.orientation === 'vertical'; }
get containerStyles() {
const styles = this.configs.containerStyle || {};
if (!('width' in styles)) {
styles['width'] = this.configs.containerWidth || '100%';
}
if (!('height' in styles)) {
styles['height'] = this.configs.containerHeight || 'auto';
}
return styles;
}
get trackStyles() {
const transform = createTranslation(this.currentTranslate, this.isVertical);
const base = { transform };
return this.isVertical ? Object.assign(Object.assign({}, base), { flexDirection: 'column' }) : base;
}
initializeCarousel() {
if (!this.trackElement || !this.wrapperElement)
return;
this.currentTranslate = 0;
this.currentIndex = 0;
// Initialize navigation buttons
this.checkOverflow();
}
/**
* Set up window resize listener
*/
setupResizeListener() {
fromEvent(window, 'resize')
.pipe(debounceTime(200), takeUntil(this.destroy$))
.subscribe(() => {
this.updateContainerDimensions();
this.calculateItemDimensions();
this.checkOverflow();
});
}
/**
* Set up intersection observer to detect when carousel becomes visible
*/
setupIntersectionObserver() {
if ('IntersectionObserver' in window) {
this.ngZone.runOutsideAngular(() => {
var _a;
this.intersectionObserver = new IntersectionObserver((entries) => {
var _a;
const isVisible = (_a = entries[0]) === null || _a === void 0 ? void 0 : _a.isIntersecting;
if (isVisible && this.initialized) {
this.ngZone.run(() => {
this.visibilityChangeTimeout = setTimeout(() => {
this.updateContainerDimensions();
this.calculateItemDimensions();
this.checkOverflow();
}, 50);
});
}
}, { threshold: 0.1 });
if ((_a = this.wrapperElement) === null || _a === void 0 ? void 0 : _a.nativeElement) {
this.intersectionObserver.observe(this.wrapperElement.nativeElement);
}
});
}
}
/**
* Set up resize observer to detect when carousel's size changes
*/
setupResizeObserver() {
if ('ResizeObserver' in window) {
this.ngZone.runOutsideAngular(() => {
var _a;
this.resizeObserver = new ResizeObserver(() => {
if (this.initialized) {
this.ngZone.run(() => {
this.updateContainerDimensions();
this.calculateItemDimensions();
this.checkOverflow();
});
}
});
if ((_a = this.wrapperElement) === null || _a === void 0 ? void 0 : _a.nativeElement) {
this.resizeObserver.observe(this.wrapperElement.nativeElement);
}
});
}
}
updateContainerDimensions() {
if (!this.wrapperElement)
return;
this.containerWidth = this.wrapperElement.nativeElement.offsetWidth;
this.containerHeight = this.wrapperElement.nativeElement.offsetHeight;
}
/**
* Check if content overflows and update button visibility
*/
checkOverflow() {
var _a, _b, _c;
// Skip check if not yet initialized to avoid change detection errors
if (!this.initialized && !((_a = this.trackElement) === null || _a === void 0 ? void 0 : _a.nativeElement))
return;
// Always show navigation in one-item mode with more than one slide
if (this.filteredItems.length > 1 && this.configs.singleItemMode) {
// In one-item mode with loop, always show both buttons if we have more than one slide
if (this.configs.loop) {
this.showPrevButton = true;
this.showNextButton = true;
return;
}
// Otherwise, buttons depend on current index
this.showPrevButton = this.currentIndex > 0;
this.showNextButton = this.currentIndex < this.filteredItems.length - 1;
return;
}
// First set default visibility based on showNavigation config
if (!this.showNavigation || this.filteredItems.length <= 1) {
this.showPrevButton = false;
this.showNextButton = false;
return;
}
const track = (_b = this.trackElement) === null || _b === void 0 ? void 0 : _b.nativeElement;
const wrapper = (_c = this.wrapperElement) === null || _c === void 0 ? void 0 : _c.nativeElement;
if (!track || !wrapper)
return;
// Skip if element has zero dimensions (may be hidden)
if (wrapper.offsetWidth === 0 || wrapper.offsetHeight === 0)
return;
// Check if there's room to scroll in either direction
// When loop is enabled, always show both buttons if there are items
if (this.configs.loop && this.filteredItems.length > 1) {
this.showPrevButton = true;
this.showNextButton = true;
}
else {
// Standard overflow checking (no loop)
this.showPrevButton = this.currentTranslate > 0;
if (this.isVertical) {
// For vertical carousels
const trackHeight = track.offsetHeight;
const wrapperHeight = wrapper.offsetHeight;
this.showNextButton = trackHeight - this.currentTranslate > wrapperHeight + 1;
}
else {
// For horizontal carousels
const trackWidth = track.offsetWidth;
const wrapperWidth = wrapper.offsetWidth;
this.showNextButton = trackWidth - this.currentTranslate > wrapperWidth + 1;
}
}
this.cdr.detectChanges();
}
/**
* Calculate dimensions of carousel items
*/
calculateItemDimensions() {
if (!this.trackElement || !this.wrapperElement)
return;
const wrapper = this.wrapperElement.nativeElement;
const containerHeight = wrapper.offsetHeight;
const containerWidth = wrapper.offsetWidth;
// Update stored container dimensions
this.containerWidth = containerWidth;
this.containerHeight = containerHeight;
const items = Array.from(this.trackElement.nativeElement.children);
if (items.length === 0)
return;
// Add a small delay to ensure images are loaded and measured correctly
setTimeout(() => {
if (this.configs.singleItemMode) {
// In single item mode, set all items to wrapper dimensions
if (this.isVertical) {
// For vertical mode, each item should be full height
this.itemHeights = items.map(() => containerHeight);
this.itemWidths = items.map((item) => {
const style = window.getComputedStyle(item);
const width = item.offsetWidth;
const marginLeft = parseInt(style.marginLeft || '0', 10);
const marginRight = parseInt(style.marginRight || '0', 10);
return width + marginLeft + marginRight;
});
}
else {
// For horizontal mode, each item should be full width
this.itemWidths = items.map(() => containerWidth);
this.itemHeights = items.map((item) => {
const style = window.getComputedStyle(item);
const height = item.offsetHeight;
const marginTop = parseInt(style.marginTop || '0', 10);
const marginBottom = parseInt(style.marginBottom || '0', 10);
return height + marginTop + marginBottom;
});
}
// Apply the calculated dimensions immediately
this.updateTranslatePosition();
this.cdr.detectChanges();
return;
}
// For multi-item mode, calculate actual dimensions
this.itemWidths = items.map((item) => {
const style = window.getComputedStyle(item);
const width = item.offsetWidth;
const marginLeft = parseInt(style.marginLeft || '0', 10);
const marginRight = parseInt(style.marginRight || '0', 10);
return width + marginLeft + marginRight;
});
this.itemHeights = items.map((item) => {
const style = window.getComputedStyle(item);
const height = item.offsetHeight;
const marginTop = parseInt(style.marginTop || '0', 10);
const marginBottom = parseInt(style.marginBottom || '0', 10);
return height + marginTop + marginBottom;
});
this.cdr.detectChanges();
}, 50); // Small delay to ensure images are loaded
}
/**
* Calculate scroll amount based on configuration
*/
getScrollAmount() {
var _a;
const wrapper = (_a = this.wrapperElement) === null || _a === void 0 ? void 0 : _a.nativeElement;
if (!wrapper)
return 0;
// For one-item-scroll mode, return item dimension
if (this.configs.singleItemMode) {
if (this.isVertical && this.itemHeights.length > 0) {
// Return height of current item or default to wrapper height
return this.currentIndex < this.itemHeights.length
? this.itemHeights[this.currentIndex]
: this.itemHeights[0] || wrapper.offsetHeight;
}
else if (this.itemWidths.length > 0) {
// Return width of current item or default to wrapper width
return this.currentIndex < this.itemWidths.length
? this.itemWidths[this.currentIndex]
: this.itemWidths[0] || wrapper.offsetWidth;
}
}
// Otherwise, use configured scroll size
const size = this.configs.scrollSize || 'sm';
if (size === 'full') {
// Full size returns container dimension
return this.isVertical ? wrapper.offsetHeight : wrapper.offsetWidth;
}
// If size is a percentage, calculate based on container size
if (typeof size === 'string' && size.endsWith('%')) {
const percent = parseInt(size, 10) / 100;
return this.isVertical
? wrapper.offsetHeight * percent
: wrapper.offsetWidth * percent;
}
// Return pixel value from map or default to small
return this.scrollSizeMap[size] || this.scrollSizeMap['sm'];
}
/**
* Navigate to previous item
*/
previous() {
if (!this.showPrevButton)
return;
if (this.configs.singleItemMode) {
if (this.currentIndex > 0) {
this.currentIndex--;
this.updateTranslatePosition();
}
else if (this.configs.loop && this.filteredItems.length > 0) {
// For loop mode, go to the last slide
this.currentIndex = this.filteredItems.length - 1;
this.updateTranslatePosition();
}
}
else {
const scrollAmount = this.getScrollAmount();
this.currentTranslate = Math.max(0, this.currentTranslate - scrollAmount);
}
this.checkOverflow();
this.onPrevClick.emit(this.currentIndex);
}
/**
* Navigate to next item
*/
next() {
var _a, _b;
if (!this.showNextButton)
return;
const track = (_a = this.trackElement) === null || _a === void 0 ? void 0 : _a.nativeElement;
const wrapper = (_b = this.wrapperElement) === null || _b === void 0 ? void 0 : _b.nativeElement;
if (!track || !wrapper)
return;
if (this.configs.singleItemMode) {
if (this.currentIndex < this.filteredItems.length - 1) {
this.currentIndex++;
this.updateTranslatePosition();
}
else if (this.configs.loop && this.filteredItems.length > 0) {
// For loop mode, go back to the first slide
this.currentIndex = 0;
this.currentTranslate = 0;
}
}
else {
const scrollAmount = this.getScrollAmount();
const maxTranslate = this.isVertical
? track.offsetHeight - wrapper.offsetHeight
: track.offsetWidth - wrapper.offsetWidth;
this.currentTranslate = Math.min(maxTranslate, this.currentTranslate + scrollAmount);
}
this.checkOverflow();
this.onNextClick.emit(this.currentIndex);
}
updateTranslatePosition() {
if (this.currentIndex === 0) {
this.currentTranslate = 0;
return;
}
const gap = this.configs.itemGap ? parseInt(this.configs.itemGap.replace('px', ''), 10) : 0;
let position = 0;
// Calculate cumulative position based on item dimensions
for (let i = 0; i < this.currentIndex; i++) {
if (this.isVertical) {
position += (this.itemHeights[i] || 0) + gap;
}
else {
position += (this.itemWidths[i] || 0) + gap;
}
}
this.currentTranslate = position;
}
getItemStyle(index) {
const styles = {
flexShrink: '0',
flexGrow: '0',
boxSizing: 'border-box',
overflow: 'hidden',
borderRadius: 'inherit',
};
if (this.configs.itemWidth) {
if (this.configs.itemWidth === '100%' && this.containerWidth > 0) {
styles['width'] = this.containerWidth + 'px';
styles['maxWidth'] = '100%';
}
else {
styles['width'] = this.configs.itemWidth;
}
}
if (this.configs.itemHeight) {
if ((this.configs.itemHeight === '100%' || parseInt(this.configs.itemHeight, 10) === this.containerHeight) && this.containerHeight > 0) {
styles['height'] = this.containerHeight + 'px';
styles['minHeight'] = this.containerHeight + 'px';
styles['maxHeight'] = '100%';
styles['display'] = 'flex';
styles['flexDirection'] = 'column';
styles['alignItems'] = 'stretch';
styles['justifyContent'] = 'stretch';
}
else {
styles['height'] = this.configs.itemHeight;
}
}
else if (this.isVertical && this.configs.containerHeight) {
styles['height'] = this.containerHeight + 'px';
styles['minHeight'] = this.containerHeight + 'px';
}
// Add margin for gap between items (except first item)
if (!this.configs.itemGap)
return styles;
const marginProperty = this.isVertical ? 'marginTop' : 'marginLeft';
styles[marginProperty] = index === 0 ? '0' : this.configs.itemGap;
return styles;
}
get contentPadding() { return this.configs.contentPadding || CAROUSEL_DEFAULTS.CONTENT_PADDING; }
get animationDuration() { return (this.configs.animationDuration || CAROUSEL_DEFAULTS.ANIMATION_DURATION); }
get animationTiming() { return this.configs.animationTiming || CAROUSEL_DEFAULTS.ANIMATION_TIMING; }
get showNavigation() { var _a; return (_a = this.configs.showNavigation) !== null && _a !== void 0 ? _a : true; }
getEmptyStateContainerStyle() {
var _a;
return {
width: '100%',
boxSizing: 'border-box',
borderRadius: 'inherit',
backgroundColor: ((_a = this.configs.emptyState) === null || _a === void 0 ? void 0 : _a.backgroundColor) || 'transparent',
};
}
hasItems() { var _a; return ((_a = this.filteredItems) === null || _a === void 0 ? void 0 : _a.length) > 0; }
getNavControlsClass() { return 'carousel__nav-controls'; }
getNavControlsStyle() {
var _a;
const styles = {
position: 'absolute',
top: '0',
left: '0',
width: '100%',
height: '100%',
pointerEvents: 'none',
};
// Apply custom z-index if specified in config
if ((_a = this.configs.navigationStyle) === null || _a === void 0 ? void 0 : _a.zIndex) {
styles['--carousel-z-index'] = this.configs.navigationStyle.zIndex;
}
return styles;
}
get prevIcon() {
var _a;
const icons = this.carouselService.getNavigationIcons(this.isVertical, (_a = this.configs.navigationStyle) === null || _a === void 0 ? void 0 : _a.icons);
return icons.prev;
}
get nextIcon() {
var _a;
const icons = this.carouselService.getNavigationIcons(this.isVertical, (_a = this.configs.navigationStyle) === null || _a === void 0 ? void 0 : _a.icons);
return icons.next;
}
getIconStyles(isNext) {
var _a, _b, _c, _d;
const color = isNext
? ((_b = (_a = this.configs.navigationStyle) === null || _a === void 0 ? void 0 : _a.nextButton) === null || _b === void 0 ? void 0 : _b.color) || '#666'
: ((_d = (_c = this.configs.navigationStyle) === null || _c === void 0 ? void 0 : _c.prevButton) === null || _d === void 0 ? void 0 : _d.color) || '#666';
return { color };
}
/**
* Calculate position styles for navigation buttons
*/
getButtonPositionStyle(button) {
const navConfig = this.configs.navigationStyle;
const buttonStyle = button === 'prev' ? (navConfig === null || navConfig === void 0 ? void 0 : navConfig.prevButton) || {} : (navConfig === null || navConfig === void 0 ? void 0 : navConfig.nextButton) || {};
const style = {
position: 'absolute',
pointerEvents: 'auto'
};
// Handle positioning properties
['top', 'bottom', 'left', 'right'].forEach(prop => {
const value = buttonStyle[prop];
if (value !== undefined) {
style[prop] = value === 0 || value === '0' ? '0px' : value;
}
});
// Handle transforms based on positioning
const transformMap = {
'left=50%': 'translateX(-50%)',
'right=50%': 'translateX(50%)',
'top=50%': 'translateY(-50%)',
'bottom=50%': 'translateY(50%)'
};
// Find matching transform
for (const [position, transform] of Object.entries(transformMap)) {
const [prop, value] = position.split('=');
if (buttonStyle[prop] === value) {
style['transform'] = buttonStyle.transform
? `${buttonStyle.transform} ${transform}`
: transform;
break;
}
}
// Use custom transform if specified
if (!style['transform'] && buttonStyle.transform) {
style['transform'] = buttonStyle.transform;
}
// Apply default positioning if none specified
const hasPosition = ['top', 'bottom', 'left', 'right'].some(prop => buttonStyle[prop] !== undefined);
if (!hasPosition) {
if (this.isVertical) {
style['left'] = '50%';
style['transform'] = 'translateX(-50%)';
style[button === 'prev' ? 'top' : 'bottom'] = '0px';
}
else {
style['top'] = '50%';
style['transform'] = 'translateY(-50%)';
style[button === 'prev' ? 'left' : 'right'] = '0px';
}
}
return style;
}
/**
* Get full styles for navigation buttons
* @param buttonType - Type of button ('prev' or 'next')
*/
getButtonFullStyles(buttonType) {
var _a, _b, _c, _d;
// Get base styles including shape styles
const styles = Object.assign({}, this.carouselService.getButtonShapeStyles((_a = this.configs.navigationStyle) === null || _a === void 0 ? void 0 : _a.buttonShape));
// Apply custom styles from config
const buttonConfig = (_b = this.configs.navigationStyle) === null || _b === void 0 ? void 0 : _b[`${buttonType}Button`];
if (buttonConfig) {
// Apply position styles first
const positionStyles = this.getButtonPositionStyle(buttonType);
Object.assign(styles, positionStyles);
// Apply custom button styles but preserve shape styles
const buttonShape = (_c = this.configs.navigationStyle) === null || _c === void 0 ? void 0 : _c.buttonShape;
if (buttonShape) {
const { borderRadius } = buttonConfig, otherButtonConfig = __rest(buttonConfig, ["borderRadius"]);
Object.assign(styles, otherButtonConfig);
}
else {
Object.assign(styles, buttonConfig);
}
// Set the CSS variable for z-index if specified in button config
if (buttonConfig.zIndex) {
styles['--carousel-z-index'] = buttonConfig.zIndex;
}
// If no button-specific z-index, but global navigation z-index exists, use that
else if ((_d = this.configs.navigationStyle) === null || _d === void 0 ? void 0 : _d.zIndex) {
styles['--carousel-z-index'] = this.configs.navigationStyle.zIndex;
}
}
return styles;
}
getPrevButtonFullStyles() { return this.getButtonFullStyles('prev'); }
getNextButtonFullStyles() { return this.getButtonFullStyles('next'); }
getPrevIndex(activeIndex) {
var _a;
const itemsCount = ((_a = this.filteredItems) === null || _a === void 0 ? void 0 : _a.length) || 0;
if (itemsCount === 0)
return activeIndex;
return activeIndex > 0
? activeIndex - 1
: this.configs.loop
? itemsCount - 1
: activeIndex;
}
goToPrevSlide() {
const prevIndex = this.getPrevIndex(this.currentIndex);
if (prevIndex !== this.currentIndex) {
this.currentIndex = prevIndex;
this.updateTranslatePosition();
this.checkOverflow();
}
}
getIndicatorContainerStyles() { return this.carouselService.getIndicatorContainerStyles(this.configs.indicatorStyle); }
/**
* Get styles for an individual indicator
* @param index Index of the indicator
*/
getIndicatorItemStyles(index) {
const isActive = index === this.currentIndex;
return this.carouselService.getIndicatorStyles(this.configs.indicatorStyle, isActive);
}
/**
* Navigate to a specific slide when indicator is clicked
* @param index Target slide index
*/
goToSlide(index) {
var _a, _b, _c;
if (index === this.currentIndex || index < 0 || index >= this.filteredItems.length)
return;
this.currentIndex = index;
if (this.configs.singleItemMode) {
this.updateTranslatePosition();
this.checkOverflow();
return;
}
const track = (_a = this.trackElement) === null || _a === void 0 ? void 0 : _a.nativeElement;
const wrapper = (_b = this.wrapperElement) === null || _b === void 0 ? void 0 : _b.nativeElement;
if (!track || !wrapper)
return;
const gap = parseInt(((_c = this.configs.itemGap) === null || _c === void 0 ? void 0 : _c.replace('px', '')) || '0', 10);
const dimensions = this.isVertical ? this.itemHeights : this.itemWidths;
// Calculate cumulative position up to target index
const position = dimensions
.slice(0, index)
.reduce((sum, size) => sum + (size || 0) + gap, 0);
const maxTranslate = this.isVertical
? track.offsetHeight - wrapper.offsetHeight
: track.offsetWidth - wrapper.offsetWidth;
this.currentTranslate = Math.min(maxTranslate, Math.max(0, position));
this.checkOverflow();
}
/**
* Whether to show indicators
*/
get showIndicators() { var _a; return (_a = this.configs.showIndicators) !== null && _a !== void 0 ? _a : false; }
}
CarouselComponent.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "14.3.0", ngImport: i0, type: CarouselComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
CarouselComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "14.3.0", type: CarouselComponent, isStandalone: true, selector: "carousel", inputs: { slides: "slides", configs: "configs", activeIndex: "activeIndex" }, outputs: { onPrevClick: "onPrevClick", onNextClick: "onNextClick" }, providers: [CarouselService], queries: [{ propertyName: "itemTemplate", first: true, predicate: ["carouselItem"], descendants: true }, { propertyName: "emptyStateTemplate", first: true, predicate: ["emptyState"], descendants: true }], viewQueries: [{ propertyName: "trackElement", first: true, predicate: ["track"], descendants: true }, { propertyName: "wrapperElement", first: true, predicate: ["wrapper"], descendants: true }], usesOnChanges: true, ngImport: i0, template: "<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]=\"getIndicatorContaine