feat(carousel): enhance carousel component with gap support and layout adjustments

This commit is contained in:
2026-07-23 11:49:23 -03:00
parent 3e98d08d59
commit 8268e58547
5 changed files with 88 additions and 43 deletions

View File

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

View File

@@ -22,7 +22,6 @@
.carousel-product { .carousel-product {
width: clamp(15rem, 30vw, 18rem); width: clamp(15rem, 30vw, 18rem);
height: 100%; height: 100%;
margin-right: 1rem;
app-product-column-with-image { app-product-column-with-image {
display: block; display: block;

View File

@@ -1,40 +1,43 @@
<section class="carousel" role="region" [attr.aria-label]="ariaLabel()"> <section class="carousel" role="region" [attr.aria-label]="ariaLabel()">
<div <div class="carousel__frame" [style.width.px]="viewportWidth()">
#viewport <div
class="carousel__viewport" #viewport
tabindex="0" class="carousel__viewport"
(scroll)="handleScroll()" tabindex="0"
(keydown)="handleKeydown($event)" [style.gap.px]="resolvedGap()"
> (scroll)="handleScroll()"
@for (item of items(); track trackBy()($index, item); let index = $index) { (keydown)="handleKeydown($event)"
<div class="carousel__item"> >
<ng-container @for (item of items(); track trackBy()($index, item); let index = $index) {
[ngTemplateOutlet]="itemTemplate()" <div class="carousel__item">
[ngTemplateOutletContext]="{ $implicit: item, index }" <ng-container
/> [ngTemplateOutlet]="itemTemplate()"
</div> [ngTemplateOutletContext]="{ $implicit: item, index }"
/>
</div>
}
</div>
@if (hasMultipleItems()) {
<button
type="button"
class="carousel__control carousel__control--previous"
[disabled]="!canScrollPrevious()"
[attr.aria-label]="previousLabel()"
(click)="previous()"
>
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
</button>
<button
type="button"
class="carousel__control carousel__control--next"
[disabled]="!canScrollNext()"
[attr.aria-label]="nextLabel()"
(click)="next()"
>
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
</button>
} }
</div> </div>
@if (hasMultipleItems()) {
<button
type="button"
class="carousel__control carousel__control--previous"
[disabled]="!canScrollPrevious()"
[attr.aria-label]="previousLabel()"
(click)="previous()"
>
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
</button>
<button
type="button"
class="carousel__control carousel__control--next"
[disabled]="!canScrollNext()"
[attr.aria-label]="nextLabel()"
(click)="next()"
>
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
</button>
}
</section> </section>

View File

@@ -4,9 +4,14 @@
} }
.carousel { .carousel {
position: relative;
width: 100%; width: 100%;
&__frame {
position: relative;
width: 100%;
margin-inline: auto;
}
&__viewport { &__viewport {
display: flex; display: flex;
width: 100%; width: 100%;

View File

@@ -31,19 +31,23 @@ export type CarouselTrackBy<T> = (index: number, item: T) => unknown;
export class CarouselComponent<T> implements AfterViewInit { export class CarouselComponent<T> implements AfterViewInit {
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly platformId = inject(PLATFORM_ID); private readonly platformId = inject(PLATFORM_ID);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly viewport = viewChild.required<ElementRef<HTMLElement>>('viewport'); private readonly viewport = viewChild.required<ElementRef<HTMLElement>>('viewport');
readonly items = input.required<readonly T[]>(); readonly items = input.required<readonly T[]>();
readonly itemTemplate = input.required<TemplateRef<CarouselItemContext<T>>>(); readonly itemTemplate = input.required<TemplateRef<CarouselItemContext<T>>>();
readonly trackBy = input<CarouselTrackBy<T>>((index) => index); readonly trackBy = input<CarouselTrackBy<T>>((index) => index);
readonly scrollStep = input<number | null>(null); readonly scrollStep = input<number | null>(null);
readonly gap = input(0);
readonly ariaLabel = input('Carrusel'); readonly ariaLabel = input('Carrusel');
readonly previousLabel = input('Mostrar elementos anteriores'); readonly previousLabel = input('Mostrar elementos anteriores');
readonly nextLabel = input('Mostrar elementos siguientes'); readonly nextLabel = input('Mostrar elementos siguientes');
protected readonly canScrollPrevious = signal(false); protected readonly canScrollPrevious = signal(false);
protected readonly canScrollNext = signal(false); protected readonly canScrollNext = signal(false);
protected readonly viewportWidth = signal<number | null>(null);
protected readonly hasMultipleItems = computed(() => this.items().length > 1); protected readonly hasMultipleItems = computed(() => this.items().length > 1);
protected readonly resolvedGap = computed(() => Math.max(this.gap(), 0));
ngAfterViewInit(): void { ngAfterViewInit(): void {
if (!isPlatformBrowser(this.platformId)) { if (!isPlatformBrowser(this.platformId)) {
@@ -51,18 +55,18 @@ export class CarouselComponent<T> implements AfterViewInit {
} }
const viewport = this.viewport().nativeElement; const viewport = this.viewport().nativeElement;
this.refreshNavigation(); this.refreshLayout();
const resizeObserver = const resizeObserver =
typeof ResizeObserver === 'undefined' typeof ResizeObserver === 'undefined'
? null ? null
: new ResizeObserver(() => this.refreshNavigation()); : new ResizeObserver(() => this.refreshLayout());
resizeObserver?.observe(viewport); resizeObserver?.observe(this.host.nativeElement);
const mutationObserver = const mutationObserver =
typeof MutationObserver === 'undefined' typeof MutationObserver === 'undefined'
? null ? null
: new MutationObserver(() => this.refreshNavigation()); : new MutationObserver(() => this.refreshLayout());
mutationObserver?.observe(viewport, { childList: true }); mutationObserver?.observe(viewport, { childList: true });
this.destroyRef.onDestroy(() => { this.destroyRef.onDestroy(() => {
@@ -109,7 +113,9 @@ export class CarouselComponent<T> implements AfterViewInit {
const viewport = this.viewport().nativeElement; const viewport = this.viewport().nativeElement;
const configuredStep = this.scrollStep(); const configuredStep = this.scrollStep();
const step = const step =
configuredStep !== null && configuredStep > 0 ? configuredStep : viewport.clientWidth; configuredStep !== null && configuredStep > 0
? configuredStep
: viewport.clientWidth + this.resolvedGap();
viewport.scrollBy({ viewport.scrollBy({
left: direction * step, left: direction * step,
@@ -125,4 +131,35 @@ export class CarouselComponent<T> implements AfterViewInit {
this.canScrollPrevious.set(viewport.scrollLeft > tolerance); this.canScrollPrevious.set(viewport.scrollLeft > tolerance);
this.canScrollNext.set(viewport.scrollLeft < maxScrollLeft - tolerance); this.canScrollNext.set(viewport.scrollLeft < maxScrollLeft - tolerance);
} }
private refreshLayout(): void {
const viewport = this.viewport().nativeElement;
const firstItem = viewport.firstElementChild as HTMLElement | null;
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, viewport.children.length);
const fittedWidth = visibleItems * itemWidth + Math.max(visibleItems - 1, 0) * gap;
this.viewportWidth.set(Math.min(Math.ceil(fittedWidth), availableWidth));
this.refreshNavigation();
}
} }