feat(carousel): adjust item width calculation for better distribution and responsiveness

This commit is contained in:
2026-07-23 13:37:10 -03:00
parent 862751fec8
commit 37bbd65cfe
5 changed files with 27 additions and 26 deletions

View File

@@ -1,9 +1,5 @@
<section class="carousel" role="region" [attr.aria-label]="ariaLabel()"> <section class="carousel" role="region" [attr.aria-label]="ariaLabel()">
<div <div class="carousel__frame" [class.carousel__frame--with-controls]="hasMultipleItems()">
class="carousel__frame"
[class.carousel__frame--with-controls]="hasMultipleItems()"
[style.width.px]="frameWidth()"
>
<div <div
#viewport #viewport
class="carousel__viewport" class="carousel__viewport"
@@ -29,7 +25,7 @@
[attr.inert]="page.slot === 'current' ? null : ''" [attr.inert]="page.slot === 'current' ? null : ''"
> >
@for (entry of page.items; track trackBy()(entry.index, entry.item)) { @for (entry of page.items; track trackBy()(entry.index, entry.item)) {
<div class="carousel__item"> <div class="carousel__item" [style.width.px]="itemWidth()">
<ng-container <ng-container
[ngTemplateOutlet]="itemTemplate()" [ngTemplateOutlet]="itemTemplate()"
[ngTemplateOutletContext]="{ $implicit: entry.item, index: entry.index }" [ngTemplateOutletContext]="{ $implicit: entry.item, index: entry.index }"
@@ -42,7 +38,7 @@
} @else { } @else {
<div class="carousel__linear-track" [style.gap.px]="resolvedGap()"> <div class="carousel__linear-track" [style.gap.px]="resolvedGap()">
@for (item of items(); track trackBy()($index, item); let index = $index) { @for (item of items(); track trackBy()($index, item); let index = $index) {
<div class="carousel__item"> <div class="carousel__item" [style.width.px]="itemWidth()">
<ng-container <ng-container
[ngTemplateOutlet]="itemTemplate()" [ngTemplateOutlet]="itemTemplate()"
[ngTemplateOutletContext]="{ $implicit: item, index }" [ngTemplateOutletContext]="{ $implicit: item, index }"

View File

@@ -79,6 +79,10 @@
flex: 0 0 auto; flex: 0 0 auto;
min-width: 0; min-width: 0;
scroll-snap-align: start; scroll-snap-align: start;
> * {
width: 100%;
}
} }
&__control { &__control {

View File

@@ -97,7 +97,7 @@ describe('CarouselComponent', () => {
expect(scrollBy).toHaveBeenCalledTimes(2); expect(scrollBy).toHaveBeenCalledTimes(2);
}); });
it('ajusta y centra el viewport para mostrar únicamente elementos completos', () => { it('usa todo el ancho y distribuye el espacio sobrante entre elementos completos', () => {
const host = fixture.nativeElement.querySelector('app-carousel') as HTMLElement; const host = fixture.nativeElement.querySelector('app-carousel') as HTMLElement;
const firstItem = fixture.nativeElement.querySelector('.carousel__item') as HTMLElement; const firstItem = fixture.nativeElement.querySelector('.carousel__item') as HTMLElement;
@@ -116,8 +116,11 @@ describe('CarouselComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
const frame = fixture.nativeElement.querySelector('.carousel__frame') as HTMLElement; const frame = fixture.nativeElement.querySelector('.carousel__frame') as HTMLElement;
const items = fixture.nativeElement.querySelectorAll('.carousel__item');
expect(frame.style.width).toBe('512px'); expect(frame.style.width).toBe('');
expect(items[0].style.width).toBe('269px');
expect(items[1].style.width).toBe('269px');
}); });
it('mantiene preparadas las páginas adyacentes y rota al terminar la transición', () => { it('mantiene preparadas las páginas adyacentes y rota al terminar la transición', () => {

View File

@@ -59,18 +59,11 @@ export class CarouselComponent<T> implements AfterViewInit {
protected readonly canScrollPrevious = signal(false); protected readonly canScrollPrevious = signal(false);
protected readonly canScrollNext = signal(false); protected readonly canScrollNext = signal(false);
protected readonly hasOverflow = signal(false); protected readonly hasOverflow = signal(false);
protected readonly viewportWidth = signal<number | null>(null); protected readonly itemWidth = signal<number | null>(null);
protected readonly itemsPerPage = signal(Number.MAX_SAFE_INTEGER); protected readonly itemsPerPage = signal(Number.MAX_SAFE_INTEGER);
protected readonly currentPage = signal(0); protected readonly currentPage = signal(0);
protected readonly transitionDirection = signal<-1 | 0 | 1>(0); protected readonly transitionDirection = signal<-1 | 0 | 1>(0);
protected readonly hasMultipleItems = computed(() => this.items().length > 1); protected readonly hasMultipleItems = computed(() => this.items().length > 1);
protected readonly frameWidth = computed(() => {
const viewportWidth = this.viewportWidth();
return viewportWidth === null
? null
: viewportWidth + (this.hasMultipleItems() ? CarouselComponent.CONTROL_SLOT_WIDTH * 2 : 0);
});
protected readonly resolvedGap = computed(() => Math.max(this.gap(), 0)); protected readonly resolvedGap = computed(() => Math.max(this.gap(), 0));
protected readonly pageCount = computed(() => protected readonly pageCount = computed(() =>
Math.max(1, Math.ceil(this.items().length / this.itemsPerPage())), Math.max(1, Math.ceil(this.items().length / this.itemsPerPage())),
@@ -234,15 +227,18 @@ export class CarouselComponent<T> implements AfterViewInit {
); );
if (!firstItem || availableViewportWidth <= 0) { if (!firstItem || availableViewportWidth <= 0) {
this.viewportWidth.set(null); this.itemWidth.set(null);
this.refreshNavigation(); this.refreshNavigation();
return; return;
} }
const itemWidth = firstItem.getBoundingClientRect().width; const assignedWidth = firstItem.style.width;
firstItem.style.width = '';
const naturalItemWidth = firstItem.getBoundingClientRect().width;
firstItem.style.width = assignedWidth;
if (itemWidth <= 0) { if (naturalItemWidth <= 0) {
this.viewportWidth.set(null); this.itemWidth.set(null);
this.refreshNavigation(); this.refreshNavigation();
return; return;
} }
@@ -250,10 +246,11 @@ export class CarouselComponent<T> implements AfterViewInit {
const gap = this.resolvedGap(); const gap = this.resolvedGap();
const fittingItems = Math.max( const fittingItems = Math.max(
1, 1,
Math.floor((availableViewportWidth + gap) / (itemWidth + gap)), Math.floor((availableViewportWidth + gap) / (naturalItemWidth + gap)),
); );
const visibleItems = Math.min(fittingItems, this.items().length); const visibleItems = Math.min(fittingItems, this.items().length);
const fittedWidth = visibleItems * itemWidth + Math.max(visibleItems - 1, 0) * gap; const distributedItemWidth =
(availableViewportWidth - Math.max(visibleItems - 1, 0) * gap) / visibleItems;
if (this.itemsPerPage() !== fittingItems) { if (this.itemsPerPage() !== fittingItems) {
this.itemsPerPage.set(fittingItems); this.itemsPerPage.set(fittingItems);
@@ -261,7 +258,7 @@ export class CarouselComponent<T> implements AfterViewInit {
this.transitionDirection.set(0); this.transitionDirection.set(0);
} }
this.viewportWidth.set(Math.min(Math.ceil(fittedWidth), availableViewportWidth)); this.itemWidth.set(distributedItemWidth);
if (this.circular()) { if (this.circular()) {
const hasOverflow = this.items().length > fittingItems; const hasOverflow = this.items().length > fittingItems;
this.hasOverflow.set(hasOverflow); this.hasOverflow.set(hasOverflow);

View File

@@ -34,12 +34,13 @@
min-width: 0; min-width: 0;
&--carousel { &--carousel {
width: clamp(15rem, 30vw, 18rem); width: 100%;
min-width: clamp(15rem, 30vw, 18rem);
height: 100%; height: 100%;
} }
&--carousel-row { &--carousel-row {
width: clamp(18rem, 70vw, 32rem); min-width: clamp(18rem, 70vw, 32rem);
} }
} }
} }