feat(carousel): add circular scrolling support and improve button disable logic
This commit is contained in:
@@ -602,6 +602,7 @@
|
||||
[itemTemplate]="productCarouselItem"
|
||||
[trackBy]="trackCarouselProduct"
|
||||
[gap]="16"
|
||||
[circular]="true"
|
||||
ariaLabel="Productos con imágenes de prueba"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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()"
|
||||
>
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user