feat(carousel): add circular scrolling support and improve button disable logic

This commit is contained in:
2026-07-23 11:53:56 -03:00
parent 326f2d9bcd
commit fb6ab2fca2
4 changed files with 78 additions and 2 deletions

View File

@@ -602,6 +602,7 @@
[itemTemplate]="productCarouselItem"
[trackBy]="trackCarouselProduct"
[gap]="16"
[circular]="true"
ariaLabel="Productos con imágenes de prueba"
/>
</div>

View File

@@ -22,7 +22,7 @@
<button
type="button"
class="carousel__control carousel__control--previous"
[disabled]="!canScrollPrevious()"
[disabled]="isPreviousDisabled()"
[attr.aria-label]="previousLabel()"
(click)="previous()"
>
@@ -32,7 +32,7 @@
<button
type="button"
class="carousel__control carousel__control--next"
[disabled]="!canScrollNext()"
[disabled]="isNextDisabled()"
[attr.aria-label]="nextLabel()"
(click)="next()"
>

View File

@@ -20,11 +20,14 @@ interface TestItem {
[itemTemplate]="itemTemplate"
[scrollStep]="120"
[gap]="16"
[circular]="circular"
ariaLabel="Productos destacados"
/>
`,
})
class TestHostComponent {
circular = false;
readonly items: TestItem[] = [
{ id: 1, label: 'Primero' },
{ id: 2, label: 'Segundo' },
@@ -116,4 +119,48 @@ describe('CarouselComponent', () => {
expect(frame.style.width).toBe('632px');
});
it('vuelve al extremo opuesto cuando circular está habilitado', () => {
fixture.componentInstance.circular = true;
fixture.detectChanges();
const viewport = fixture.nativeElement.querySelector('.carousel__viewport') as HTMLElement;
const scrollTo = vi.fn();
Object.defineProperties(viewport, {
clientWidth: {
configurable: true,
value: 300,
},
scrollWidth: {
configurable: true,
value: 900,
},
scrollLeft: {
configurable: true,
writable: true,
value: 600,
},
});
viewport.scrollTo = scrollTo;
viewport.dispatchEvent(new Event('scroll'));
fixture.detectChanges();
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',
});
});
});

View File

@@ -39,15 +39,23 @@ export class CarouselComponent<T> implements AfterViewInit {
readonly trackBy = input<CarouselTrackBy<T>>((index) => index);
readonly scrollStep = input<number | null>(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<number | null>(null);
protected readonly hasMultipleItems = computed(() => this.items().length > 1);
protected readonly resolvedGap = computed(() => Math.max(this.gap(), 0));
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)) {
@@ -74,10 +82,20 @@ export class CarouselComponent<T> implements AfterViewInit {
}
previous(): void {
if (this.circular() && this.hasOverflow() && !this.canScrollPrevious()) {
this.scrollToEdge('end');
return;
}
this.scroll(-1);
}
next(): void {
if (this.circular() && this.hasOverflow() && !this.canScrollNext()) {
this.scrollToEdge('start');
return;
}
this.scroll(1);
}
@@ -126,10 +144,20 @@ export class CarouselComponent<T> implements AfterViewInit {
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 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;