UNPKG

ng-smooth-carousel

Version:

A smooth, customizable carousel component for Angular 14 applications

965 lines (957 loc) 119 kB
import { Component, Input, ContentChild, ViewChild } from '@angular/core'; import { Subject, fromEvent } from 'rxjs'; import { takeUntil, debounceTime } from 'rxjs/operators'; import * as i0 from "@angular/core"; import * as i1 from "@angular/common"; /** * NgSmoothCarousel Component * * A carousel/slider component for Angular applications with smooth scrolling capabilities. * Supports both horizontal and vertical orientations, custom navigation styling, * and multiple configuration options. */ export class CarouselComponent { constructor(cdr, ngZone) { this.cdr = cdr; this.ngZone = ngZone; this.items = []; this.config = {}; this.currentTranslate = 0; this.currentIndex = 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; 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.searchQuery = ''; this.isSearchModalOpen = false; this.filteredItems = []; } /** * @description Initialize component on init */ ngOnInit() { this.filteredItems = this.items || []; } /** * @description Handle changes to inputs */ ngOnChanges(changes) { if (changes['items']) { this.filteredItems = this.items || []; if (this.initialized) { setTimeout(() => { this.updateContainerWidth(); this.calculateItemWidths(); this.checkOverflow(); this.cdr.detectChanges(); }); } } } /** * @description Convert time string to milliseconds */ parseTimeToMs(time) { 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); } /** * @description Setup autoplay functionality if enabled */ setupAutoplay() { if (this.autoplayInterval) clearInterval(this.autoplayInterval); if (!this.config.autoplay) return; const delay = this.parseTimeToMs(this.config.autoplayDelay || '3000ms'); this.autoplayInterval = setInterval(() => { const t = this.trackElement?.nativeElement; const w = this.wrapperElement?.nativeElement; if (!t || !w) return; const max = this.isVertical ? t.offsetHeight - w.offsetHeight : t.offsetWidth - w.offsetWidth; if (this.currentTranslate >= max) { if (this.config.loop) this.currentTranslate = 0; else { clearInterval(this.autoplayInterval); return; } } else this.next(); this.cdr.detectChanges(); }, delay); } /** * @description Initialize component after view is initialized */ ngAfterViewInit() { this.initializeCarousel(); this.setupResizeListener(); this.setupIntersectionObserver(); this.setupResizeObserver(); this.setupAutoplay(); setTimeout(() => { this.updateContainerWidth(); this.calculateItemWidths(); this.checkOverflow(); this.initialized = true; }); } /** * @description Clean up resources on component destruction */ 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 = []; } /** * @description Whether carousel orientation is vertical */ get isVertical() { return this.config.orientation === 'vertical'; } /** * @description Get container styles based on configuration */ get containerStyles() { return { width: this.config.containerWidth || '100%', height: this.config.containerHeight || 'auto', }; } /** * @description Get track styles including transform property for translation */ get trackStyles() { const base = { transform: this.isVertical ? `translateY(-${this.currentTranslate}px)` : `translateX(-${this.currentTranslate}px)`, }; return this.isVertical ? { ...base, flexDirection: 'column' } : base; } /** * @description Initialize carousel state */ initializeCarousel() { if (!this.trackElement || !this.wrapperElement) return; this.currentTranslate = 0; this.checkOverflow(); } setupResizeListener() { fromEvent(window, 'resize') .pipe(debounceTime(200), takeUntil(this.destroy$)) .subscribe(() => { this.updateContainerWidth(); this.calculateItemWidths(); this.checkOverflow(); }); } setupIntersectionObserver() { // Create an IntersectionObserver to detect when the carousel becomes visible if ('IntersectionObserver' in window) { this.ngZone.runOutsideAngular(() => { this.intersectionObserver = new IntersectionObserver(entries => { const isVisible = entries[0]?.isIntersecting; if (isVisible && this.initialized) { // When component becomes visible, recalculate dimensions and update buttons this.ngZone.run(() => { // Use a small timeout to ensure the DOM has settled this.visibilityChangeTimeout = setTimeout(() => { this.updateContainerWidth(); this.calculateItemWidths(); this.checkOverflow(); }, 50); }); } }, { threshold: 0.1 }); if (this.wrapperElement?.nativeElement) { this.intersectionObserver.observe(this.wrapperElement.nativeElement); } }); } } setupResizeObserver() { // Create a ResizeObserver to detect when the carousel's size changes if ('ResizeObserver' in window) { this.ngZone.runOutsideAngular(() => { this.resizeObserver = new ResizeObserver(entries => { if (this.initialized) { this.ngZone.run(() => { this.updateContainerWidth(); this.calculateItemWidths(); this.checkOverflow(); }); } }); if (this.wrapperElement?.nativeElement) { this.resizeObserver.observe(this.wrapperElement.nativeElement); } }); } } updateContainerWidth() { if (!this.wrapperElement) return; this.containerWidth = this.wrapperElement.nativeElement.offsetWidth; this.containerHeight = this.wrapperElement.nativeElement.offsetHeight; } /** * @description Check if content overflows and update button visibility */ checkOverflow() { if (!this.showNavigation) { this.showPrevButton = this.showNextButton = false; return; } const t = this.trackElement?.nativeElement; const w = this.wrapperElement?.nativeElement; if (!t || !w) return; // Skip if element has zero dimensions (may be hidden) if (w.offsetWidth === 0 || w.offsetHeight === 0) return; if (this.config.enableOneItemScroll) { // In one-item mode, buttons depend on current index this.showPrevButton = this.currentIndex > 0; this.showNextButton = this.currentIndex < this.filteredItems.length - 1; } else if (this.isVertical) { // In vertical mode, check translation against content height this.showPrevButton = this.currentTranslate > 0; this.showNextButton = t.offsetHeight - this.currentTranslate > w.offsetHeight; } else { // In horizontal mode, check translation against content width this.showPrevButton = this.currentTranslate > 0; this.showNextButton = t.offsetWidth - this.currentTranslate > w.offsetWidth; } this.cdr.detectChanges(); } calculateItemWidths() { if (!this.trackElement || !this.wrapperElement) return; const w = this.wrapperElement.nativeElement; const items = Array.from(this.trackElement.nativeElement.children); if (this.config.enableOneItemScroll) { if (this.isVertical) { const pHeight = w.offsetHeight; this.itemHeights = items.map(() => pHeight); this.itemWidths = items.map(item => { const style = window.getComputedStyle(item); const width = item.offsetWidth; const ml = parseInt(style.marginLeft || '0', 10); const mr = parseInt(style.marginRight || '0', 10); return width + ml + mr; }); } else { const pWidth = w.offsetWidth; this.itemWidths = items.map(() => pWidth); this.itemHeights = items.map(item => { const style = window.getComputedStyle(item); const height = item.offsetHeight; const mt = parseInt(style.marginTop || '0', 10); const mb = parseInt(style.marginBottom || '0', 10); return height + mt + mb; }); } return; } this.itemWidths = items.map(item => { const style = window.getComputedStyle(item); const width = item.offsetWidth; const ml = parseInt(style.marginLeft || '0', 10); const mr = parseInt(style.marginRight || '0', 10); return width + ml + mr; }); this.itemHeights = items.map(item => { const style = window.getComputedStyle(item); const height = item.offsetHeight; const mt = parseInt(style.marginTop || '0', 10); const mb = parseInt(style.marginBottom || '0', 10); return height + mt + mb; }); } /** * @description Calculate scroll amount based on configuration */ getScrollAmount() { // For one-item-scroll mode, return item dimension if (this.config.enableOneItemScroll) { 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] || this.wrapperElement.nativeElement.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] || this.wrapperElement.nativeElement.offsetWidth); } } // Otherwise, use configured scroll size const size = this.config.scrollSize || 'sm'; if (size === 'full') { // Full size returns container dimension return this.isVertical ? this.wrapperElement.nativeElement.offsetHeight : this.wrapperElement.nativeElement.offsetWidth; } // Return pixel value from map or default to small return this.scrollSizeMap[size] || this.scrollSizeMap['sm']; } /** * @description Navigate to previous item */ previous() { if (this.config.enableOneItemScroll) { if (this.currentIndex > 0) { this.currentIndex--; if (this.currentIndex === 0) { this.currentTranslate = 0; } else { const gap = this.config.itemGap ? parseInt(this.config.itemGap.replace('px', ''), 10) : 0; if (this.isVertical) { const ih = this.itemHeights[this.currentIndex] || this.wrapperElement.nativeElement.offsetHeight; this.currentTranslate = this.currentIndex * (ih + gap); } else { const iw = this.itemWidths[this.currentIndex] || this.wrapperElement.nativeElement.offsetWidth; this.currentTranslate = this.currentIndex * (iw + gap); } } } } else { const scrollAmount = this.getScrollAmount(); this.currentTranslate = Math.max(0, this.currentTranslate - scrollAmount); } this.checkOverflow(); } /** * @description Navigate to next item */ next() { const t = this.trackElement.nativeElement; const w = this.wrapperElement.nativeElement; if (this.config.enableOneItemScroll) { if (this.currentIndex < this.filteredItems.length - 1) { const gap = this.config.itemGap ? parseInt(this.config.itemGap.replace('px', ''), 10) : 0; this.currentIndex++; if (this.isVertical) { const ih = this.itemHeights[this.currentIndex - 1] || w.offsetHeight; if (this.config.itemHeight === '100%') { this.currentTranslate = this.currentIndex * (ih + gap); } else { this.currentTranslate += ih + gap; } } else { const iw = this.itemWidths[this.currentIndex - 1] || w.offsetWidth; if (this.config.itemWidth === '100%') { this.currentTranslate = this.currentIndex * (iw + gap); } else { this.currentTranslate += iw + gap; } } } } else { const amt = this.getScrollAmount(); const max = this.isVertical ? t.offsetHeight - w.offsetHeight : t.offsetWidth - w.offsetWidth; this.currentTranslate = Math.min(max, this.currentTranslate + amt); } this.checkOverflow(); } /** * @description Get button shape styles based on configuration */ getButtonShapeStyles() { const shape = this.config.navigationStyle?.buttonShape; switch (shape) { case 'circle': return { borderRadius: '50%' }; case 'rounded': return { borderRadius: '4px' }; default: return { borderRadius: '0' }; } } /** * @description Get styles for next button */ get nextButtonStyles() { const bs = { ...(this.config.navigationStyle?.nextButton || {}) }; const ss = this.getButtonShapeStyles(); if (this.config.navigationStyle?.buttonShape && bs) delete bs['borderRadius']; return { ...ss, ...bs }; } /** * @description Get styles for previous button */ get prevButtonStyles() { const bs = { ...(this.config.navigationStyle?.prevButton || {}) }; const ss = this.getButtonShapeStyles(); if (this.config.navigationStyle?.buttonShape && bs) delete bs['borderRadius']; return { ...ss, ...bs }; } /** * @description Get styles for search button * @deprecated COMING SOON - Search functionality will be available in future releases */ // get searchButtonStyles(): Record<string, any> { // const bs = { ...(this.config.searchStyle?.button || {}) }; // const ss = this.getButtonShapeStyles(); // if (this.config.navigationStyle?.buttonShape && bs) delete bs['borderRadius']; // return { ...ss, ...bs }; // } /** * @description Get individual item styling */ getItemStyle(index) { const s = { flexShrink: '0', flexGrow: '0', boxSizing: 'border-box', overflow: 'hidden', borderRadius: 'inherit' }; // Handle width calculation if (this.config.itemWidth) { if (this.config.itemWidth === '100%' && this.config.enableOneItemScroll && this.containerWidth > 0) { // For full-width items, adjust based on orientation const widthAdjustment = this.isVertical ? 4 : 0; s['width'] = (this.containerWidth - widthAdjustment) + 'px'; s['maxWidth'] = '100%'; } else { s['width'] = this.config.itemWidth; } } else { s['width'] = '100%'; } // Handle height calculation if (this.config.itemHeight) { if (this.config.itemHeight === '100%' && this.wrapperElement) { const containerHeight = this.wrapperElement.nativeElement.offsetHeight; if (containerHeight > 0) { // For full-height items, adjust based on orientation const heightAdjustment = this.isVertical ? 0 : 4; s['height'] = (containerHeight - heightAdjustment) + 'px'; s['maxHeight'] = '100%'; } else { s['height'] = this.config.itemHeight; } } else { s['height'] = this.config.itemHeight; } } else { s['height'] = 'auto'; } // Add margin for gap between items (except first item) if (!this.config.itemGap) return s; const m = this.isVertical ? 'marginTop' : 'marginLeft'; return { ...s, [m]: index === 0 ? '0' : this.config.itemGap }; } /** * @description Content padding for the carousel */ get contentPadding() { return this.config.contentPadding || '4px'; } /** * @description Animation duration for scrolling */ get animationDuration() { return this.config.animationDuration || '300ms'; } /** * @description Animation timing function */ get animationTiming() { return this.config.animationTiming || 'ease'; } /** * @description Whether search functionality is enabled * @deprecated COMING SOON - Search functionality will be available in future releases */ // get showSearch(): boolean { return this.config.enableSearch ?? false; } /** * @description Placeholder text for search input * @deprecated COMING SOON - Search functionality will be available in future releases */ // get searchPlaceholder(): string { return this.config.searchPlaceholder || 'Search...'; } /** * @description Title for search modal * @deprecated COMING SOON - Search functionality will be available in future releases */ // get searchModalTitle(): string { return this.config.searchModalTitle || 'Filter Items'; } /** * @description Styles for search modal * @deprecated COMING SOON - Search functionality will be available in future releases */ // get searchModalStyles(): Record<string, string> { return this.config.searchStyle?.modal || {}; } /** * @description Toggle search modal visibility * @deprecated COMING SOON - Search functionality will be available in future releases */ // toggleSearchModal(): void { // this.isSearchModalOpen = !this.isSearchModalOpen; // if (this.isSearchModalOpen && this.searchInput) { // setTimeout(() => this.searchInput.nativeElement.focus()); // } // } /** * @description Close search modal * @deprecated COMING SOON - Search functionality will be available in future releases */ // closeSearchModal(): void { // this.isSearchModalOpen = false; // } /** * @description Apply search filter to items * @deprecated COMING SOON - Search functionality will be available in future releases */ // applySearch(): void { // const q = this.searchQuery.trim().toLowerCase(); // if (!q) { // this.filteredItems = this.items; // } else { // this.filteredItems = this.items.filter(item => { // if (typeof item === 'string') return item.toLowerCase().includes(q); // return Object.values(item).some(v => typeof v === 'string' && v.toLowerCase().includes(q)); // }); // } // this.currentTranslate = 0; // this.currentIndex = 0; // this.checkOverflow(); // this.closeSearchModal(); // } /** * @description Handle clicks outside search modal * @deprecated COMING SOON - Search functionality will be available in future releases */ // @HostListener('document:click', ['$event']) // handleClickOutside(event: MouseEvent): void { // const sc = document.querySelector('.nsc__search'); // if (this.isSearchModalOpen && sc && !sc.contains(event.target as Node)) { // this.closeSearchModal(); // } // } /** * @description Reset search and show all items * @deprecated COMING SOON - Search functionality will be available in future releases */ // resetSearch(): void { // this.searchQuery = ''; // this.filteredItems = this.items; // this.currentTranslate = 0; // this.currentIndex = 0; // setTimeout(() => { // this.updateContainerWidth(); // this.calculateItemWidths(); // this.checkOverflow(); // }); // } /** * @description Get navigation icons based on configuration */ getNavigationIcons() { const defIcons = { horizontal: { prev: '❮', next: '❯' }, vertical: { prev: '❮', next: '❯' }, // search: '🔍' }; const cfgIcons = this.config.navigationStyle?.icons || {}; const vIcons = cfgIcons.vertical || {}; // Get icons based on orientation return { prev: this.isVertical ? (vIcons.prev || defIcons.vertical.prev) : (cfgIcons.prev || defIcons.horizontal.prev), next: this.isVertical ? (vIcons.next || defIcons.vertical.next) : (cfgIcons.next || defIcons.horizontal.next), // search: cfgIcons.search || defIcons.search }; } /** * @description Previous button icon */ get prevIcon() { return this.getNavigationIcons().prev; } /** * @description Next button icon */ get nextIcon() { return this.getNavigationIcons().next; } /** * @description Search button icon * @deprecated COMING SOON - Search functionality will be available in future releases */ // get searchIcon(): string { return this.getNavigationIcons().search; } /** * @description Next button icon styles */ get nextIconStyles() { return { color: (this.config.navigationStyle?.nextButton || {})['color'] || '#666' }; } /** * @description Previous button icon styles */ get prevIconStyles() { return { color: (this.config.navigationStyle?.prevButton || {})['color'] || '#666' }; } /** * @description Search button icon styles * @deprecated COMING SOON - Search functionality will be available in future releases */ // get searchIconStyles(): Record<string, string> { // return { color: (this.config.searchStyle?.button || {})['color'] || '#666' }; // } /** * @description Whether navigation buttons should be shown */ get showNavigation() { return this.config.showNavigation ?? true; } /** * @description Get styles for empty state container */ getEmptyStateContainerStyle() { return { width: '100%', boxSizing: 'border-box', borderRadius: 'inherit' }; } /** * @description Determines if carousel contains items */ hasItems() { return this.filteredItems?.length > 0; } /** * @description Default CTA position (fixed to bottom-right) * @deprecated Will be removed in future versions */ get ctaPosition() { return 'bottom-right'; } /** * @description Get class for navigation controls container */ getNavControlsClass() { return 'nsc__nav-controls'; } /** * @description Get styles for navigation controls container */ getNavControlsStyle() { return { position: 'absolute', top: '0', left: '0', width: '100%', height: '100%', pointerEvents: 'none', zIndex: '100' }; } /** * @description Calculate position styles for navigation buttons */ getButtonPositionStyle(button) { const navConfig = this.config.navigationStyle; const buttonStyle = button === 'prev' ? navConfig?.prevButton || {} : navConfig?.nextButton || {}; // Create a base style object with high z-index const style = { zIndex: buttonStyle['zIndex'] || '1000', position: 'absolute', pointerEvents: 'auto' }; // Apply direct positioning if specified if (buttonStyle['top'] !== undefined) style['top'] = buttonStyle['top']; if (buttonStyle['bottom'] !== undefined) style['bottom'] = buttonStyle['bottom']; if (buttonStyle['left'] !== undefined) style['left'] = buttonStyle['left']; if (buttonStyle['right'] !== undefined) style['right'] = buttonStyle['right']; // Set transform if specified if (buttonStyle['transform'] !== undefined) { style['transform'] = buttonStyle['transform']; } // Apply default positioning if no position is provided if (buttonStyle['top'] === undefined && buttonStyle['bottom'] === undefined && buttonStyle['left'] === undefined && buttonStyle['right'] === undefined) { // Use default center positions style['top'] = '50%'; style['transform'] = 'translateY(-50%)'; if (button === 'prev') { style['left'] = '-10px'; } else { style['right'] = '-10px'; } } return style; } /** * @description Get full styles for previous button */ getPrevButtonFullStyles() { const styles = { ...this.prevButtonStyles }; // If the z-index is specified, ensure it's applied with higher priority if (styles['zIndex']) { styles['zIndex'] = (parseInt(styles['zIndex']) || 0).toString(); } // Apply position styles if needed const positionStyles = this.getButtonPositionStyle('prev'); return { ...positionStyles, ...styles }; } /** * @description Get full styles for next button */ getNextButtonFullStyles() { const styles = { ...this.nextButtonStyles }; // If the z-index is specified, ensure it's applied with higher priority if (styles['zIndex']) { styles['zIndex'] = (parseInt(styles['zIndex']) || 0).toString(); } // Apply position styles if needed const positionStyles = this.getButtonPositionStyle('next'); return { ...positionStyles, ...styles }; } } CarouselComponent.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "14.3.0", ngImport: i0, type: CarouselComponent, deps: [{ token: i0.ChangeDetectorRef }, { token: i0.NgZone }], target: i0.ɵɵFactoryTarget.Component }); CarouselComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "14.3.0", type: CarouselComponent, selector: "nsc", inputs: { items: "items", config: "config" }, 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 }, { propertyName: "searchInput", first: true, predicate: ["searchInput"], descendants: true }], usesOnChanges: true, ngImport: i0, template: ` <div [class.nsc--vertical]="isVertical" [ngStyle]="containerStyles" class="nsc"> <div #wrapper [style.--content-padding]="contentPadding" class="nsc__wrapper"> <div #track [ngStyle]="trackStyles" [class.nsc__track--vertical]="isVertical" [style.--animation-duration]="animationDuration" [style.--animation-timing]="animationTiming" class="nsc__track"> <ng-container *ngIf="hasItems(); else emptyState"> <ng-container *ngFor="let item of filteredItems; let i = index"> <div class="nsc__item" [ngStyle]="getItemStyle(i)"> <ng-container *ngIf="itemTemplate; else defaultTemplate"> <ng-container *ngTemplateOutlet="itemTemplate; context: { $implicit: item, index: i }"></ng-container> </ng-container> <ng-template #defaultTemplate> <div class="nsc__item-default"> {{ item }} </div> </ng-template> </div> </ng-container> </ng-container> <ng-template #emptyState> <div class="nsc__empty-container" [ngStyle]="getEmptyStateContainerStyle()"> <ng-container *ngIf="emptyStateTemplate; else defaultEmptyState"> <ng-container *ngTemplateOutlet="emptyStateTemplate; context: { $implicit: 'No items found' }"></ng-container> </ng-container> <ng-template #defaultEmptyState> <div class="nsc__empty-state"> <div class="nsc__empty-icon">📭</div> <div class="nsc__empty-text">No items found</div> <!-- <button class="nsc__reset-button" (click)="resetSearch()"> Show all items </button> --> </div> </ng-template> </div> </ng-template> </div> </div> <div *ngIf="showNavigation && hasItems()" [ngClass]="getNavControlsClass()" [ngStyle]="getNavControlsStyle()"> <!-- <div *ngIf="showSearch" class="nsc__search" style="position:absolute; bottom:16px; right:16px; pointer-events:auto;"> <button [ngStyle]="searchButtonStyles" (click)="toggleSearchModal()" class="nsc__nav-button nsc__nav-button--search"> <span class="nsc__nav-icon" [ngStyle]="searchIconStyles">{{ searchIcon }}</span> </button> <div *ngIf="isSearchModalOpen" [class.nsc__dropdown--vertical]="isVertical" (click)="$event.stopPropagation()" class="nsc__dropdown" > <input type="text" [placeholder]="searchPlaceholder" [(ngModel)]="searchQuery" (keyup.enter)="applySearch()" class="nsc__search-input" #searchInput /> </div> </div> --> <button [class.nsc__nav-button--disabled]="!showPrevButton" [disabled]="!showPrevButton" [ngStyle]="getPrevButtonFullStyles()" (click)="previous()" style="pointer-events:auto;" class="nsc__nav-button"> <span class="nsc__nav-icon" [ngStyle]="prevIconStyles">{{ prevIcon }}</span> </button> <button [class.nsc__nav-button--disabled]="!showNextButton" [disabled]="!showNextButton" [ngStyle]="getNextButtonFullStyles()" (click)="next()" style="pointer-events:auto;" class="nsc__nav-button"> <span class="nsc__nav-icon" [ngStyle]="nextIconStyles">{{ nextIcon }}</span> </button> </div> </div> `, isInline: true, styles: [".nsc{position:relative;overflow:hidden;display:flex;flex-direction:column;border-radius:inherit}.nsc--vertical{flex-direction:column}.nsc__wrapper{flex:1;overflow:hidden;position:relative;padding:var(--content-padding,10px) 0;width:100%;border-radius:inherit}.nsc--vertical .nsc__wrapper{padding:0 var(--content-padding,10px)}.nsc__track{display:flex;flex-wrap:nowrap;transition:transform var(--animation-duration,.3s) var(--animation-timing,ease);width:fit-content;min-width:100%;border-radius:inherit}.nsc__track--vertical{flex-direction:column;width:100%;height:fit-content}.nsc__item{flex:0 0 auto;box-sizing:border-box;border-radius:inherit}.nsc--vertical .nsc__item{width:100%}.nsc__item-default{background:#fff;height:100%;display:flex;align-items:center;justify-content:center;border:1px solid #e0e0e0;border-radius:inherit;padding:20px}.nsc__empty-container{width:100%;box-sizing:border-box;border-radius:inherit}.nsc__empty-state{background:#fff;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:30px;text-align:center;color:#666;border-radius:inherit;min-height:200px}.nsc__nav-controls{position:absolute;display:flex;gap:24px;z-index:100;pointer-events:auto}.nsc__nav-button{background:#fff;border:1px solid #e0e0e0;width:32px;height:32px;padding:0;margin:0;display:flex;align-items:center;justify-content:center;cursor:pointer;z-index:101;position:relative}.nsc__nav-icon{display:flex;align-items:center;justify-content:center;width:100%;height:100%;font-size:16px;line-height:1}.nsc__nav-button:hover:not(.nsc__nav-button--disabled){opacity:.9}.nsc__nav-button--disabled{opacity:.4;cursor:not-allowed;background-color:#f5f5f5;border-color:#ddd}.nsc__search{position:relative}.nsc__dropdown{position:absolute;background:#fff;border:1px solid #e0e0e0;border-radius:4px;box-shadow:0 2px 8px #0000001a;z-index:1000;min-width:200px;top:100%;right:0;margin-top:8px}.nsc__dropdown--vertical{right:auto;left:100%;top:0;margin-top:0;margin-left:8px}.nsc__search-input{width:100%;padding:8px 12px;border:none;border-radius:4px;font-size:14px;outline:none}.nsc__empty-icon{font-size:32px;margin-bottom:12px}.nsc__empty-text{font-size:16px;margin-bottom:12px}.nsc__reset-button{background:none;border:none;padding:6px 12px;font-size:13px;color:#007bff;cursor:pointer;transition:opacity .2s ease}.nsc__reset-button:hover{opacity:.7}.nsc--vertical .nsc__nav-button .nsc__nav-icon{transform:rotate(90deg)}\n"], dependencies: [{ kind: "directive", type: i1.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i1.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type: i1.NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }] }); i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "14.3.0", ngImport: i0, type: CarouselComponent, decorators: [{ type: Component, args: [{ selector: 'nsc', template: ` <div [class.nsc--vertical]="isVertical" [ngStyle]="containerStyles" class="nsc"> <div #wrapper [style.--content-padding]="contentPadding" class="nsc__wrapper"> <div #track [ngStyle]="trackStyles" [class.nsc__track--vertical]="isVertical" [style.--animation-duration]="animationDuration" [style.--animation-timing]="animationTiming" class="nsc__track"> <ng-container *ngIf="hasItems(); else emptyState"> <ng-container *ngFor="let item of filteredItems; let i = index"> <div class="nsc__item" [ngStyle]="getItemStyle(i)"> <ng-container *ngIf="itemTemplate; else defaultTemplate"> <ng-container *ngTemplateOutlet="itemTemplate; context: { $implicit: item, index: i }"></ng-container> </ng-container> <ng-template #defaultTemplate> <div class="nsc__item-default"> {{ item }} </div> </ng-template> </div> </ng-container> </ng-container> <ng-template #emptyState> <div class="nsc__empty-container" [ngStyle]="getEmptyStateContainerStyle()"> <ng-container *ngIf="emptyStateTemplate; else defaultEmptyState"> <ng-container *ngTemplateOutlet="emptyStateTemplate; context: { $implicit: 'No items found' }"></ng-container> </ng-container> <ng-template #defaultEmptyState> <div class="nsc__empty-state"> <div class="nsc__empty-icon">📭</div> <div class="nsc__empty-text">No items found</div> <!-- <button class="nsc__reset-button" (click)="resetSearch()"> Show all items </button> --> </div> </ng-template> </div> </ng-template> </div> </div> <div *ngIf="showNavigation && hasItems()" [ngClass]="getNavControlsClass()" [ngStyle]="getNavControlsStyle()"> <!-- <div *ngIf="showSearch" class="nsc__search" style="position:absolute; bottom:16px; right:16px; pointer-events:auto;"> <button [ngStyle]="searchButtonStyles" (click)="toggleSearchModal()" class="nsc__nav-button nsc__nav-button--search"> <span class="nsc__nav-icon" [ngStyle]="searchIconStyles">{{ searchIcon }}</span> </button> <div *ngIf="isSearchModalOpen" [class.nsc__dropdown--vertical]="isVertical" (click)="$event.stopPropagation()" class="nsc__dropdown" > <input type="text" [placeholder]="searchPlaceholder" [(ngModel)]="searchQuery" (keyup.enter)="applySearch()" class="nsc__search-input" #searchInput /> </div> </div> --> <button [class.nsc__nav-button--disabled]="!showPrevButton" [disabled]="!showPrevButton" [ngStyle]="getPrevButtonFullStyles()" (click)="previous()" style="pointer-events:auto;" class="nsc__nav-button"> <span class="nsc__nav-icon" [ngStyle]="prevIconStyles">{{ prevIcon }}</span> </button> <button [class.nsc__nav-button--disabled]="!showNextButton" [disabled]="!showNextButton" [ngStyle]="getNextButtonFullStyles()" (click)="next()" style="pointer-events:auto;" class="nsc__nav-button"> <span class="nsc__nav-icon" [ngStyle]="nextIconStyles">{{ nextIcon }}</span> </button> </div> </div> `, styles: [".nsc{position:relative;overflow:hidden;display:flex;flex-direction:column;border-radius:inherit}.nsc--vertical{flex-direction:column}.nsc__wrapper{flex:1;overflow:hidden;position:relative;padding:var(--content-padding,10px) 0;width:100%;border-radius:inherit}.nsc--vertical .nsc__wrapper{padding:0 var(--content-padding,10px)}.nsc__track{display:flex;flex-wrap:nowrap;transition:transform var(--animation-duration,.3s) var(--animation-timing,ease);width:fit-content;min-width:100%;border-radius:inherit}.nsc__track--vertical{flex-direction:column;width:100%;height:fit-content}.nsc__item{flex:0 0 auto;box-sizing:border-box;border-radius:inherit}.nsc--vertical .nsc__item{width:100%}.nsc__item-default{background:#fff;height:100%;display:flex;align-items:center;justify-content:center;border:1px solid #e0e0e0;border-radius:inherit;padding:20px}.nsc__empty-container{width:100%;box-sizing:border-box;border-radius:inherit}.nsc__empty-state{background:#fff;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:30px;text-align:center;color:#666;border-radius:inherit;min-height:200px}.nsc__nav-controls{position:absolute;display:flex;gap:24px;z-index:100;pointer-events:auto}.nsc__nav-button{background:#fff;border:1px solid #e0e0e0;width:32px;height:32px;padding:0;margin:0;display:flex;align-items:center;justify-content:center;cursor:pointer;z-index:101;position:relative}.nsc__nav-icon{display:flex;align-items:center;justify-content:center;width:100%;height:100%;font-size:16px;line-height:1}.nsc__nav-button:hover:not(.nsc__nav-button--disabled){opacity:.9}.nsc__nav-button--disabled{opacity:.4;cursor:not-allowed;background-color:#f5f5f5;border-color:#ddd}.nsc__search{position:relative}.nsc__dropdown{position:absolute;background:#fff;border:1px solid #e0e0e0;border-radius:4px;box-shadow:0 2px 8px #0000001a;z-index:1000;min-width:200px;top:100%;right:0;margin-top:8px}.nsc__dropdown--vertical{right:auto;left:100%;top:0;margin-top:0;margin-left:8px}.nsc__search-input{width:100%;padding:8px 12px;border:none;border-radius:4px;font-size:14px;outline:none}.nsc__empty-icon{font-size:32px;margin-bottom:12px}.nsc__empty-text{font-size:16px;margin-bottom:12px}.nsc__reset-button{background:none;border:none;padding:6px 12px;font-size:13px;color:#007bff;cursor:pointer;transition:opacity .2s ease}.nsc__reset-button:hover{opacity:.7}.nsc--vertical .nsc__nav-button .nsc__nav-icon{transform:rotate(90deg)}\n"] }] }], ctorParameters: function () { return [{ type: i0.ChangeDetectorRef }, { type: i0.NgZone }]; }, propDecorators: { items: [{ type: Input }], config: [{ type: Input }], itemTemplate: [{ type: ContentChild, args: ['carouselItem'] }], emptyStateTemplate: [{ type: ContentChild, args: ['emptyState'] }], trackElement: [{ type: ViewChild, args: ['track'] }], wrapperElement: [{ type: ViewChild, args: ['wrapper'] }], searchInput: [{ type: ViewChild, args: ['searchInput'] }] } }); //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2Fyb3VzZWwuY29tcG9uZW50LmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vLi4vLi4vcHJvamVjdHMvbmctc21vb3RoLWNhcm91c2VsL3NyYy9saWIvY2Fyb3VzZWwuY29tcG9uZW50LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sRUFDTCxTQUFTLEVBQ1QsS0FBSyxFQUNMLFlBQVksRUFLWixTQUFTLEVBTVYsTUFBTSxlQUFlLENBQUM7QUFDdkIsT0FBTyxFQUFFLE9BQU8sRUFBRSxTQUFTLEVBQUUsTUFBTSxNQUFNLENBQUM7QUFDMUMsT0FBTyxFQUFFLFNBQVMsRUFBRSxZQUFZLEVBQUUsTUFBTSxnQkFBZ0IsQ0FBQzs7O0FBR3pEOzs7Ozs7R0FNRztBQXVISCxNQUFNLE9BQU8saUJBQWlCO0lBbUM1QixZQUE2QixHQUFzQixFQUFtQixNQUFjO1FBQXZELFFBQUcsR0FBSCxHQUFHLENBQW1CO1FBQW1CLFdBQU0sR0FBTixNQUFNLENBQVE7UUFsQzNFLFVBQUssR0FBVSxFQUFFLENBQUM7UUFDbEIsV0FBTSxHQUFtQixFQUFFLENBQUM7UUFRN0IscUJBQWdCLEdBQUcsQ0FBQyxDQUFDO1FBQ3JCLGlCQUFZLEdBQUcsQ0FBQyxDQUFDO1FBQ2pCLGFBQVEsR0FBRyxJQUFJLE9BQU8sRUFBUSxDQUFDO1FBRS9CLGVBQVUsR0FBYSxFQUFFLENBQUM7UUFDMUIsZ0JBQVcsR0FBYSxFQUFFLENBQUM7UUFDM0IsbUJBQWMsR0FBVyxDQUFDLENBQUM7UUFDM0Isb0JBQWUsR0FBVyxDQUFDLENBQUM7UUFDNUIseUJBQW9CLEdBQWdDLElBQUksQ0FBQztRQUN6RCxtQkFBYyxHQUEwQixJQUFJLENBQUM7UUFDN0MsNEJBQXVCLEdBQVEsSUFBSSxDQUFDO1FBQ3BDLGdCQUFXLEdBQUcsS0FBSyxDQUFDO1FBRVgsa0JBQWEsR0FBRztZQUMvQixJQUFJLEVBQUUsRUFBRSxFQUFFLElBQUksRUFBRSxHQUFHLEVBQUUsSUFBSSxFQUFF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