feat(carousel): enhance image loading strategy and improve product display in carousel

This commit is contained in:
2026-07-23 14:44:52 -03:00
parent b04c33bbff
commit 67702ffdd7
8 changed files with 127 additions and 6 deletions

View File

@@ -4,6 +4,7 @@
class="store-home__main-carousel"
[images]="mainCarouselImages"
ariaLabel="Imágenes destacadas de la tienda"
(firstImageReady)="mainCarouselReady.set(true)"
/>
}
}
@@ -31,6 +32,7 @@
[groupLayout]="group.group_layout"
[items]="group.items"
[loading]="isGroupLoading(group.id)"
[loadImages]="!hasMainCarouselImages() || mainCarouselReady()"
(buy)="onBuyProduct($event)"
(addToCart)="onAddToCart($event)"
(pageChange)="onPageChange(group.id, $event)"

View File

@@ -176,7 +176,14 @@ describe('StoreHomePageComponent', () => {
expect(element.firstElementChild).toBe(carousel);
expect(images).toHaveLength(2);
expect(images?.[0].getAttribute('src')).toBe(mainCarouselImages[0]);
expect(images?.[1].getAttribute('src')).toBeNull();
expect(element.querySelectorAll('app-product-column-with-image img')).toHaveLength(0);
images?.[0].dispatchEvent(new Event('load'));
fixture.detectChanges();
expect(images?.[1].getAttribute('src')).toBe(mainCarouselImages[1]);
expect(element.querySelectorAll('app-product-column-with-image img')).toHaveLength(1);
fixture.destroy();
});

View File

@@ -3,6 +3,7 @@ import {
Component,
OnDestroy,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
@@ -53,6 +54,10 @@ export class StoreHomePageComponent implements OnInit, OnDestroy {
protected readonly loading = signal(false);
protected readonly loadingGroupIds = signal<ReadonlySet<number>>(new Set());
protected readonly error = signal<string | null>(null);
protected readonly mainCarouselReady = signal(false);
protected readonly hasMainCarouselImages = computed(
() => (this.tenant()?.main_carousel_images?.length ?? 0) > 0,
);
private catalogRequestSubscription: Subscription | null = null;
private readonly groupRequestSubscriptions = new Map<number, Subscription>();

View File

@@ -15,11 +15,14 @@
<img
class="main-carousel__image"
[class.main-carousel__image--active]="index === activeIndex()"
[src]="image"
[attr.src]="imageSource(image, index)"
[alt]="imageAlt(index)"
[attr.aria-hidden]="index === activeIndex() ? null : 'true'"
[attr.loading]="index === 0 ? 'eager' : 'lazy'"
loading="eager"
[attr.fetchpriority]="index === activeIndex() ? 'high' : 'low'"
draggable="false"
(load)="handleImageLoad(index)"
(error)="handleImageError(index)"
/>
}
</div>

View File

@@ -41,6 +41,29 @@ describe('MainCarouselComponent', () => {
expect(images[0].classList).toContain('main-carousel__image--active');
});
it('carga las imágenes secuencialmente y prioriza la imagen visible', () => {
const images = fixture.nativeElement.querySelectorAll(
'.main-carousel__image',
) as NodeListOf<HTMLImageElement>;
expect(images[0].getAttribute('src')).toBe('/images/one.webp');
expect(images[0].getAttribute('fetchpriority')).toBe('high');
expect(images[1].getAttribute('src')).toBeNull();
expect(images[2].getAttribute('src')).toBeNull();
images[0].dispatchEvent(new Event('load'));
fixture.detectChanges();
expect(images[1].getAttribute('src')).toBe('/images/two.webp');
expect(images[1].getAttribute('fetchpriority')).toBe('low');
expect(images[2].getAttribute('src')).toBeNull();
images[1].dispatchEvent(new Event('load'));
fixture.detectChanges();
expect(images[2].getAttribute('src')).toBe('/images/three.webp');
});
it('avanza automáticamente entre las imágenes', () => {
fixture.destroy();
vi.useFakeTimers();
@@ -48,10 +71,18 @@ describe('MainCarouselComponent', () => {
try {
fixture = TestBed.createComponent(TestHostComponent);
fixture.detectChanges();
const images = fixture.nativeElement.querySelectorAll(
'.main-carousel__image',
) as NodeListOf<HTMLImageElement>;
images[0].dispatchEvent(new Event('load'));
fixture.detectChanges();
images[1].dispatchEvent(new Event('load'));
fixture.detectChanges();
vi.advanceTimersByTime(1000);
fixture.detectChanges();
const images = fixture.nativeElement.querySelectorAll('.main-carousel__image');
expect(images[1].classList).toContain('main-carousel__image--active');
} finally {
fixture.destroy();

View File

@@ -2,10 +2,12 @@ import {
ChangeDetectionStrategy,
Component,
PLATFORM_ID,
WritableSignal,
computed,
effect,
inject,
input,
output,
signal,
} from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
@@ -20,11 +22,15 @@ export class MainCarouselComponent {
private readonly platformId = inject(PLATFORM_ID);
private readonly requestedIndex = signal(0);
private readonly paused = signal(false);
private readonly loadedIndexes = signal<ReadonlySet<number>>(new Set());
private readonly settledIndexes = signal<ReadonlySet<number>>(new Set());
private firstImageReadyEmitted = false;
readonly images = input.required<readonly string[]>();
readonly imageAlts = input<readonly string[]>([]);
readonly autoSlideInterval = input(5000);
readonly ariaLabel = input('Imágenes destacadas');
readonly firstImageReady = output<void>();
protected readonly activeIndex = computed(() => {
const imageCount = this.images().length;
@@ -32,8 +38,27 @@ export class MainCarouselComponent {
return imageCount ? this.requestedIndex() % imageCount : 0;
});
protected readonly hasMultipleImages = computed(() => this.images().length > 1);
protected readonly sequentialLoadIndex = computed(() => {
const settledIndexes = this.settledIndexes();
for (let index = 0; index < this.images().length; index += 1) {
if (!settledIndexes.has(index)) {
return index;
}
}
return -1;
});
constructor() {
effect(() => {
this.images();
this.requestedIndex.set(0);
this.loadedIndexes.set(new Set());
this.settledIndexes.set(new Set());
this.firstImageReadyEmitted = false;
});
effect(() => {
const imageCount = this.images().length;
const interval = Math.max(this.autoSlideInterval(), 1000);
@@ -44,7 +69,11 @@ export class MainCarouselComponent {
const timer = window.setInterval(() => {
if (!this.paused()) {
this.requestedIndex.update((index) => (index + 1) % imageCount);
this.requestedIndex.update((index) => {
const nextIndex = (index + 1) % imageCount;
return this.loadedIndexes().has(nextIndex) ? nextIndex : index;
});
}
}, interval);
@@ -62,6 +91,27 @@ export class MainCarouselComponent {
this.paused.set(paused);
}
protected imageSource(image: string, index: number): string | null {
const shouldLoad =
this.loadedIndexes().has(index) ||
index === this.sequentialLoadIndex() ||
index === this.activeIndex();
return shouldLoad ? image : null;
}
protected handleImageLoad(index: number): void {
this.updateIndexSet(this.loadedIndexes, index, true);
this.updateIndexSet(this.settledIndexes, index, true);
this.notifyFirstImageReady(index);
}
protected handleImageError(index: number): void {
this.updateIndexSet(this.loadedIndexes, index, false);
this.updateIndexSet(this.settledIndexes, index, true);
this.notifyFirstImageReady(index);
}
protected handleKeydown(event: KeyboardEvent): void {
if (!this.hasMultipleImages()) {
return;
@@ -83,4 +133,26 @@ export class MainCarouselComponent {
protected imageAlt(index: number): string {
return this.imageAlts()[index] || `Imagen destacada ${index + 1}`;
}
private updateIndexSet(
target: WritableSignal<ReadonlySet<number>>,
index: number,
present: boolean,
): void {
target.update((current) => {
const updated = new Set(current);
present ? updated.add(index) : updated.delete(index);
return updated;
});
}
private notifyFirstImageReady(index: number): void {
if (index !== 0 || this.firstImageReadyEmitted) {
return;
}
this.firstImageReadyEmitted = true;
this.firstImageReady.emit();
}
}

View File

@@ -28,10 +28,10 @@
}
@default {
<app-product-column-with-image
[imageUrl]="item.image ?? null"
[imageUrl]="loadImages() ? (item.image ?? null) : null"
[title]="item.nombre"
[originalPrice]="price(item)"
[imagePriority]="index < 4"
[imagePriority]="loadImages() && index < 4"
(buy)="buy.emit(item)"
/>
}

View File

@@ -59,6 +59,7 @@ export class ProductListComponent {
readonly groupLayout = input.required<CatalogGroupLayout>();
readonly items = input.required<CatalogFeaturedItems>();
readonly loading = input(false);
readonly loadImages = input(true);
readonly buy = output<ProductListItem>();
readonly addToCart = output<ProductListCartEvent>();