feat(carousel): enhance image loading strategy and improve product display in carousel
This commit is contained in:
@@ -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)"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
Reference in New Issue
Block a user