diff --git a/src/app/shared/components/carousel/carousel.component.html b/src/app/shared/components/carousel/carousel.component.html
index 907e474..3e73b1e 100644
--- a/src/app/shared/components/carousel/carousel.component.html
+++ b/src/app/shared/components/carousel/carousel.component.html
@@ -3,17 +3,48 @@
- @for (item of items(); track trackBy()($index, item); let index = $index) {
-
-
+ @if (isCircularLayout()) {
+
+ @for (page of circularPages(); track page.slot) {
+
+ @for (entry of page.items; track trackBy()(entry.index, entry.item)) {
+
+
+
+ }
+
+ }
+
+ } @else {
+
+ @for (item of items(); track trackBy()($index, item); let index = $index) {
+
+
+
+ }
}
diff --git a/src/app/shared/components/carousel/carousel.component.scss b/src/app/shared/components/carousel/carousel.component.scss
index be6e564..f739084 100644
--- a/src/app/shared/components/carousel/carousel.component.scss
+++ b/src/app/shared/components/carousel/carousel.component.scss
@@ -13,7 +13,6 @@
}
&__viewport {
- display: flex;
width: 100%;
overflow-x: auto;
overscroll-behavior-inline: contain;
@@ -29,6 +28,39 @@
outline: 2px solid var(--color-primary, currentColor);
outline-offset: 2px;
}
+
+ &--circular {
+ overflow: hidden;
+ scroll-snap-type: none;
+ }
+ }
+
+ &__linear-track,
+ &__page {
+ display: flex;
+ }
+
+ &__track {
+ display: flex;
+ width: 300%;
+ transform: translateX(calc(-100% / 3));
+
+ &--moving {
+ transition: transform 300ms ease;
+ }
+
+ &--previous {
+ transform: translateX(0);
+ }
+
+ &--next {
+ transform: translateX(calc(-200% / 3));
+ }
+ }
+
+ &__page {
+ flex: 0 0 calc(100% / 3);
+ min-width: 0;
}
&__item {
@@ -77,4 +109,8 @@
.carousel__viewport {
scroll-behavior: auto;
}
+
+ .carousel__track--moving {
+ transition-duration: 1ms;
+ }
}
diff --git a/src/app/shared/components/carousel/carousel.component.spec.ts b/src/app/shared/components/carousel/carousel.component.spec.ts
index f1df348..48e6098 100644
--- a/src/app/shared/components/carousel/carousel.component.spec.ts
+++ b/src/app/shared/components/carousel/carousel.component.spec.ts
@@ -1,4 +1,4 @@
-import { Component, TemplateRef, viewChild } from '@angular/core';
+import { Component, signal, TemplateRef, viewChild } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CarouselComponent, CarouselItemContext } from './carousel.component';
@@ -20,13 +20,13 @@ interface TestItem {
[itemTemplate]="itemTemplate"
[scrollStep]="120"
[gap]="16"
- [circular]="circular"
+ [circular]="circular()"
ariaLabel="Productos destacados"
/>
`,
})
class TestHostComponent {
- circular = false;
+ readonly circular = signal(false);
readonly items: TestItem[] = [
{ id: 1, label: 'Primero' },
@@ -121,46 +121,44 @@ describe('CarouselComponent', () => {
});
it('vuelve al extremo opuesto cuando circular está habilitado', () => {
- fixture.componentInstance.circular = true;
+ fixture.componentInstance.circular.set(true);
fixture.detectChanges();
+ expect(fixture.componentInstance.carousel().circular()).toBe(true);
- const viewport = fixture.nativeElement.querySelector('.carousel__viewport') as HTMLElement;
- const scrollTo = vi.fn();
+ const host = fixture.nativeElement.querySelector('app-carousel') as HTMLElement;
+ const firstItem = fixture.nativeElement.querySelector('.carousel__item') as HTMLElement;
- Object.defineProperties(viewport, {
- clientWidth: {
- configurable: true,
- value: 300,
- },
- scrollWidth: {
- configurable: true,
- value: 900,
- },
- scrollLeft: {
- configurable: true,
- writable: true,
- value: 600,
- },
+ Object.defineProperty(host, 'clientWidth', {
+ configurable: true,
+ value: 250,
});
- viewport.scrollTo = scrollTo;
+ firstItem.getBoundingClientRect = vi.fn(
+ () =>
+ ({
+ width: 100,
+ }) as DOMRect,
+ );
- viewport.dispatchEvent(new Event('scroll'));
+ fixture.componentInstance.carousel().ngAfterViewInit();
fixture.detectChanges();
+
+ const pages = fixture.nativeElement.querySelectorAll('.carousel__page');
+ expect(pages).toHaveLength(3);
+ expect(pages[0].textContent).toContain('Tercero');
+ expect(pages[1].textContent).toContain('Primero');
+ expect(pages[2].textContent).toContain('Tercero');
+
fixture.componentInstance.carousel().next();
-
- expect(scrollTo).toHaveBeenNthCalledWith(1, {
- left: 0,
- behavior: 'smooth',
- });
-
- viewport.scrollLeft = 0;
- viewport.dispatchEvent(new Event('scroll'));
fixture.detectChanges();
- fixture.componentInstance.carousel().previous();
- expect(scrollTo).toHaveBeenNthCalledWith(2, {
- left: 600,
- behavior: 'smooth',
- });
+ const track = fixture.nativeElement.querySelector('.carousel__track') as HTMLElement;
+ expect(track.classList).toContain('carousel__track--next');
+
+ track.dispatchEvent(new TransitionEvent('transitionend', { propertyName: 'transform' }));
+ fixture.detectChanges();
+
+ const recenteredPages = fixture.nativeElement.querySelectorAll('.carousel__page');
+ expect(track.classList).not.toContain('carousel__track--next');
+ expect(recenteredPages[1].textContent).toContain('Tercero');
});
});
diff --git a/src/app/shared/components/carousel/carousel.component.ts b/src/app/shared/components/carousel/carousel.component.ts
index 76b8a9e..ed42be5 100644
--- a/src/app/shared/components/carousel/carousel.component.ts
+++ b/src/app/shared/components/carousel/carousel.component.ts
@@ -21,6 +21,16 @@ export interface CarouselItemContext
{
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],
@@ -48,8 +58,25 @@ export class CarouselComponent implements AfterViewInit {
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()),
);
@@ -73,7 +100,7 @@ export class CarouselComponent implements AfterViewInit {
typeof MutationObserver === 'undefined'
? null
: new MutationObserver(() => this.refreshLayout());
- mutationObserver?.observe(viewport, { childList: true });
+ mutationObserver?.observe(viewport, { childList: true, subtree: true });
this.destroyRef.onDestroy(() => {
resizeObserver?.disconnect();
@@ -82,8 +109,8 @@ export class CarouselComponent implements AfterViewInit {
}
previous(): void {
- if (this.circular() && this.hasOverflow() && !this.canScrollPrevious()) {
- this.scrollToEdge('end');
+ if (this.circular()) {
+ this.moveCircular(-1);
return;
}
@@ -91,8 +118,8 @@ export class CarouselComponent implements AfterViewInit {
}
next(): void {
- if (this.circular() && this.hasOverflow() && !this.canScrollNext()) {
- this.scrollToEdge('start');
+ if (this.circular()) {
+ this.moveCircular(1);
return;
}
@@ -100,6 +127,16 @@ export class CarouselComponent implements AfterViewInit {
}
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.children.item(index) as HTMLElement | null;
item?.scrollIntoView({
@@ -110,9 +147,28 @@ export class CarouselComponent implements AfterViewInit {
}
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();
@@ -139,6 +195,14 @@ export class CarouselComponent implements AfterViewInit {
});
}
+ 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);
@@ -149,18 +213,9 @@ export class CarouselComponent implements AfterViewInit {
this.canScrollNext.set(viewport.scrollLeft < maxScrollLeft - tolerance);
}
- private scrollToEdge(edge: 'start' | 'end'): void {
- const viewport = this.viewport().nativeElement;
-
- viewport.scrollTo({
- left: edge === 'start' ? 0 : viewport.scrollWidth - viewport.clientWidth,
- behavior: 'smooth',
- });
- }
-
private refreshLayout(): void {
const viewport = this.viewport().nativeElement;
- const firstItem = viewport.firstElementChild as HTMLElement | null;
+ const firstItem = viewport.querySelector('.carousel__item');
const availableWidth = this.host.nativeElement.clientWidth;
if (!firstItem || availableWidth <= 0) {
@@ -179,10 +234,41 @@ export class CarouselComponent implements AfterViewInit {
const gap = this.resolvedGap();
const fittingItems = Math.max(1, Math.floor((availableWidth + gap) / (itemWidth + gap)));
- const visibleItems = Math.min(fittingItems, viewport.children.length);
+ 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));
- this.refreshNavigation();
+ 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 end = Math.min(start + this.itemsPerPage(), this.items().length);
+ const items: CarouselPageItem[] = [];
+
+ for (let index = start; index < end; index += 1) {
+ items.push({ item: this.items()[index], index });
+ }
+
+ return { slot, items };
+ }
+
+ private normalizePage(page: number, pageCount: number): number {
+ return ((page % pageCount) + pageCount) % pageCount;
}
}