feat(carousel): enhance carousel component with gap support and layout adjustments
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user