import { AfterViewInit, ChangeDetectionStrategy, Component, DestroyRef, ElementRef, PLATFORM_ID, TemplateRef, computed, inject, input, signal, viewChild, } from '@angular/core'; import { isPlatformBrowser, NgTemplateOutlet } from '@angular/common'; export interface CarouselItemContext { $implicit: T; index: number; } export type CarouselTrackBy = (index: number, item: T) => unknown; interface CarouselPageItem { item: T; index: number; } interface CarouselPage { slot: 'previous' | 'current' | 'next'; items: CarouselPageItem[]; } @Component({ selector: 'app-carousel', imports: [NgTemplateOutlet], templateUrl: './carousel.component.html', styleUrl: './carousel.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class CarouselComponent implements AfterViewInit { private readonly destroyRef = inject(DestroyRef); private readonly platformId = inject(PLATFORM_ID); private readonly host = inject>(ElementRef); private readonly viewport = viewChild.required>('viewport'); readonly items = input.required(); readonly itemTemplate = input.required>>(); readonly trackBy = input>((index) => index); readonly scrollStep = input(null); readonly gap = input(0); readonly circular = input(false); readonly ariaLabel = input('Carrusel'); readonly previousLabel = input('Mostrar elementos anteriores'); readonly nextLabel = input('Mostrar elementos siguientes'); protected readonly canScrollPrevious = signal(false); protected readonly canScrollNext = signal(false); protected readonly hasOverflow = signal(false); protected readonly viewportWidth = signal(null); protected readonly itemsPerPage = signal(Number.MAX_SAFE_INTEGER); protected readonly currentPage = signal(0); protected readonly transitionDirection = signal<-1 | 0 | 1>(0); protected readonly hasMultipleItems = computed(() => this.items().length > 1); protected readonly resolvedGap = computed(() => Math.max(this.gap(), 0)); protected readonly pageCount = computed(() => Math.max(1, Math.ceil(this.items().length / this.itemsPerPage())), ); protected readonly isCircularLayout = computed(() => this.circular() && this.pageCount() > 1); protected readonly circularPages = computed[]>(() => { const pageCount = this.pageCount(); const currentPage = this.normalizePage(this.currentPage(), pageCount); return [ this.createPage('previous', currentPage - 1), this.createPage('current', currentPage), this.createPage('next', currentPage + 1), ]; }); protected readonly isPreviousDisabled = computed( () => !this.hasOverflow() || (!this.circular() && !this.canScrollPrevious()), ); protected readonly isNextDisabled = computed( () => !this.hasOverflow() || (!this.circular() && !this.canScrollNext()), ); ngAfterViewInit(): void { if (!isPlatformBrowser(this.platformId)) { return; } const viewport = this.viewport().nativeElement; this.refreshLayout(); const resizeObserver = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(() => this.refreshLayout()); resizeObserver?.observe(this.host.nativeElement); const mutationObserver = typeof MutationObserver === 'undefined' ? null : new MutationObserver(() => this.refreshLayout()); mutationObserver?.observe(viewport, { childList: true, subtree: true }); this.destroyRef.onDestroy(() => { resizeObserver?.disconnect(); mutationObserver?.disconnect(); }); } previous(): void { if (this.circular()) { this.moveCircular(-1); return; } this.scroll(-1); } next(): void { if (this.circular()) { this.moveCircular(1); return; } this.scroll(1); } scrollTo(index: number): void { if (this.circular()) { if (index < 0 || index >= this.items().length) { return; } this.currentPage.set(Math.floor(index / this.itemsPerPage())); this.transitionDirection.set(0); return; } const item = this.viewport() .nativeElement.querySelectorAll('.carousel__item') .item(index); item?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start', }); } protected handleScroll(): void { if (this.circular()) { return; } this.refreshNavigation(); } protected finishCircularTransition(event: TransitionEvent): void { if (event.target !== event.currentTarget || event.propertyName !== 'transform') { return; } const direction = this.transitionDirection(); if (direction === 0) { return; } this.currentPage.update((page) => this.normalizePage(page + direction, this.pageCount())); this.transitionDirection.set(0); } protected handleKeydown(event: KeyboardEvent): void { if (event.key === 'ArrowLeft') { event.preventDefault(); this.previous(); } if (event.key === 'ArrowRight') { event.preventDefault(); this.next(); } } private scroll(direction: -1 | 1): void { const viewport = this.viewport().nativeElement; const configuredStep = this.scrollStep(); const step = configuredStep !== null && configuredStep > 0 ? configuredStep : viewport.clientWidth + this.resolvedGap(); viewport.scrollBy({ left: direction * step, behavior: 'smooth', }); } private moveCircular(direction: -1 | 1): void { if (!this.hasOverflow() || this.transitionDirection() !== 0) { return; } this.transitionDirection.set(direction); } private refreshNavigation(): void { const viewport = this.viewport().nativeElement; const maxScrollLeft = Math.max(viewport.scrollWidth - viewport.clientWidth, 0); const tolerance = 1; this.hasOverflow.set(maxScrollLeft > tolerance); this.canScrollPrevious.set(viewport.scrollLeft > tolerance); this.canScrollNext.set(viewport.scrollLeft < maxScrollLeft - tolerance); } private refreshLayout(): void { const viewport = this.viewport().nativeElement; const firstItem = viewport.querySelector('.carousel__item'); const availableWidth = this.host.nativeElement.clientWidth; if (!firstItem || availableWidth <= 0) { this.viewportWidth.set(null); this.refreshNavigation(); return; } const itemWidth = firstItem.getBoundingClientRect().width; if (itemWidth <= 0) { this.viewportWidth.set(null); this.refreshNavigation(); return; } const gap = this.resolvedGap(); const fittingItems = Math.max(1, Math.floor((availableWidth + gap) / (itemWidth + gap))); const visibleItems = Math.min(fittingItems, this.items().length); const fittedWidth = visibleItems * itemWidth + Math.max(visibleItems - 1, 0) * gap; if (this.itemsPerPage() !== fittingItems) { this.itemsPerPage.set(fittingItems); this.currentPage.set(0); this.transitionDirection.set(0); } this.viewportWidth.set(Math.min(Math.ceil(fittedWidth), availableWidth)); if (this.circular()) { const hasOverflow = this.items().length > fittingItems; this.hasOverflow.set(hasOverflow); this.canScrollPrevious.set(hasOverflow); this.canScrollNext.set(hasOverflow); } else { this.refreshNavigation(); } } private createPage(slot: CarouselPage['slot'], page: number): CarouselPage { const pageCount = this.pageCount(); const normalizedPage = this.normalizePage(page, pageCount); const start = normalizedPage * this.itemsPerPage(); const items: CarouselPageItem[] = []; for (let offset = 0; offset < this.itemsPerPage(); offset += 1) { const index = (start + offset) % this.items().length; items.push({ item: this.items()[index], index }); } return { slot, items }; } private normalizePage(page: number, pageCount: number): number { return ((page % pageCount) + pageCount) % pageCount; } }