feat(carousel): implement product carousel component with mock data and styles

This commit is contained in:
2026-07-23 11:47:10 -03:00
parent 20c8b04638
commit 3e98d08d59
8 changed files with 542 additions and 61 deletions

View File

@@ -574,6 +574,38 @@
</div>
</section>
<section class="component-demo card shadow-sm mt-4" data-testid="products-carousel">
<div class="card-body">
<span class="eyebrow">Reusable Component</span>
<h2 class="mb-3">Carousel de productos</h2>
<p class="text-muted mb-4">
El carousel controla únicamente el desplazamiento. Cada ítem se define externamente con un
template y en este ejemplo renderiza productos con imágenes mockeadas.
</p>
<ng-template #productCarouselItem let-product let-index="index">
<div class="carousel-product" [attr.data-carousel-index]="index">
<app-product-column-with-image
[imageUrl]="product.imageUrl"
[title]="product.title"
[originalPrice]="product.originalPrice"
[discount]="product.discount"
[transferPrice]="product.transferPrice"
(buy)="onProductBuy(product.title)"
/>
</div>
</ng-template>
<app-carousel
[items]="carouselProducts"
[itemTemplate]="productCarouselItem"
[trackBy]="trackCarouselProduct"
ariaLabel="Productos con imágenes de prueba"
/>
</div>
</section>
<section class="component-demo card shadow-sm mt-4">
<div class="card-body">
<span class="eyebrow">Reusable Component</span>

View File

@@ -19,6 +19,17 @@
width: min(100%, 64rem);
}
.carousel-product {
width: clamp(15rem, 30vw, 18rem);
height: 100%;
margin-right: 1rem;
app-product-column-with-image {
display: block;
height: 100%;
}
}
.eyebrow {
display: inline-block;
margin-bottom: 0.75rem;
@@ -155,7 +166,6 @@ h1 {
gap: 0.75rem;
}
.file-field {
display: grid;
gap: 0.75rem;
@@ -168,6 +178,10 @@ h1 {
}
@media (max-width: 767.98px) {
.carousel-product {
width: min(18rem, calc(100vw - 5rem));
}
.input-grid {
grid-template-columns: minmax(0, 1fr);
}
@@ -206,11 +220,15 @@ h1 {
padding: 1rem;
overflow: hidden;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.05);
transition: transform 0.2s ease, box-shadow 0.2s ease;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.05), 0 4px 12px rgba(0, 0, 0, 0.08);
box-shadow:
inset 0 2px 4px rgba(0, 0, 0, 0.05),
0 4px 12px rgba(0, 0, 0, 0.08);
}
}
@@ -251,7 +269,9 @@ h1 {
border: 1px solid #dee2e6;
overflow: hidden;
background-color: #ffffff;
transition: transform 0.2s ease, box-shadow 0.2s ease;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
&:hover {
transform: translateY(-3px);

View File

@@ -1,9 +1,6 @@
import { signal } from '@angular/core';
import { TestBed, getTestBed } from '@angular/core/testing';
import {
BrowserTestingModule,
platformBrowserTesting
} from '@angular/platform-browser/testing';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import { of } from 'rxjs';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
@@ -25,7 +22,7 @@ const tenant: Tenant = {
header_bg_color: '#313131',
footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png'
footer_logo: 'https://example.com/footer.png',
};
function createTenantServiceStub(currentTenant: Tenant | null) {
@@ -36,7 +33,7 @@ function createTenantServiceStub(currentTenant: Tenant | null) {
tenant: tenantState.asReadonly(),
status: statusState.asReadonly(),
getTenant: () => tenantState(),
bootstrap: vi.fn().mockResolvedValue(undefined)
bootstrap: vi.fn().mockResolvedValue(undefined),
};
}
@@ -44,7 +41,7 @@ function createToastServiceStub() {
return {
success: vi.fn(),
danger: vi.fn(),
info: vi.fn()
info: vi.fn(),
};
}
@@ -52,17 +49,14 @@ function createModalServiceStub() {
return {
open: vi.fn(),
openConfirm: vi.fn().mockReturnValue(of(true)),
openConfirmDelete: vi.fn().mockReturnValue(of(false))
openConfirmDelete: vi.fn().mockReturnValue(of(false)),
};
}
describe('ReutilizablesTestPageComponent', () => {
beforeAll(() => {
try {
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting()
);
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
} catch {
// Test environment may already be initialized by another setup entrypoint.
}
@@ -79,17 +73,17 @@ describe('ReutilizablesTestPageComponent', () => {
providers: [
{
provide: TenantService,
useValue: createTenantServiceStub(tenant)
useValue: createTenantServiceStub(tenant),
},
{
provide: ToastService,
useValue: createToastServiceStub()
useValue: createToastServiceStub(),
},
{
provide: ModalService,
useValue: modalServiceStub
}
]
useValue: modalServiceStub,
},
],
}).compileComponents();
const fixture = TestBed.createComponent(ReutilizablesTestPageComponent);
@@ -101,12 +95,12 @@ describe('ReutilizablesTestPageComponent', () => {
expect(sectionTitle?.textContent).toContain('Configuracion Tenant');
const headerImg = element.querySelector(
'.tenant-logo-card img[alt="Header Logo"]'
'.tenant-logo-card img[alt="Header Logo"]',
) as HTMLImageElement;
expect(headerImg?.src).toBe(tenant.header_logo);
const footerImg = element.querySelector(
'.tenant-logo-card img[alt="Footer Logo"]'
'.tenant-logo-card img[alt="Footer Logo"]',
) as HTMLImageElement;
expect(footerImg?.src).toBe(tenant.footer_logo);
@@ -123,17 +117,17 @@ describe('ReutilizablesTestPageComponent', () => {
providers: [
{
provide: TenantService,
useValue: createTenantServiceStub(tenant)
useValue: createTenantServiceStub(tenant),
},
{
provide: ToastService,
useValue: createToastServiceStub()
useValue: createToastServiceStub(),
},
{
provide: ModalService,
useValue: modalServiceStub
}
]
useValue: modalServiceStub,
},
],
}).compileComponents();
const fixture = TestBed.createComponent(ReutilizablesTestPageComponent);
@@ -142,9 +136,43 @@ describe('ReutilizablesTestPageComponent', () => {
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('app-paginator')).not.toBeNull();
expect(
element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()
).toBe('1/8');
expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe(
'1/8',
);
});
it('renders the carousel demo with mocked product images', async () => {
await TestBed.configureTestingModule({
imports: [ReutilizablesTestPageComponent],
providers: [
{
provide: TenantService,
useValue: createTenantServiceStub(tenant),
},
{
provide: ToastService,
useValue: createToastServiceStub(),
},
{
provide: ModalService,
useValue: createModalServiceStub(),
},
],
}).compileComponents();
const fixture = TestBed.createComponent(ReutilizablesTestPageComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const carouselDemo = element.querySelector('[data-testid="products-carousel"]');
const carouselItems = carouselDemo?.querySelectorAll('[data-carousel-index]');
const productImages = carouselDemo?.querySelectorAll('app-product-column-with-image img');
expect(carouselDemo?.querySelector('app-carousel')).not.toBeNull();
expect(carouselItems).toHaveLength(6);
expect(productImages).toHaveLength(6);
expect(productImages?.[0].getAttribute('src')).toContain('/images/pantalon-scuba.png');
expect(productImages?.[1].getAttribute('src')).toContain('/images/zapatillas-running.png');
});
it('renders the modal showcase and opens the confirm demos from the buttons', async () => {
@@ -154,36 +182,34 @@ describe('ReutilizablesTestPageComponent', () => {
providers: [
{
provide: TenantService,
useValue: createTenantServiceStub(tenant)
useValue: createTenantServiceStub(tenant),
},
{
provide: ToastService,
useValue: createToastServiceStub()
useValue: createToastServiceStub(),
},
{
provide: ModalService,
useValue: modalServiceStub
}
]
useValue: modalServiceStub,
},
],
}).compileComponents();
const fixture = TestBed.createComponent(ReutilizablesTestPageComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const buttons = Array.from(
element.querySelectorAll('.button-group app-button button')
);
const buttons = Array.from(element.querySelectorAll('.button-group app-button button'));
const confirmButton = buttons.find((button) =>
button.textContent?.includes('Abrir confirm modal')
button.textContent?.includes('Abrir confirm modal'),
) as HTMLButtonElement;
const deleteButton = buttons.find((button) =>
button.textContent?.includes('Abrir confirm delete')
button.textContent?.includes('Abrir confirm delete'),
) as HTMLButtonElement;
expect(
element.querySelector('[data-testid="modal-last-result"]')?.textContent
).toContain('Todavia no se abrio ningun modal.');
expect(element.querySelector('[data-testid="modal-last-result"]')?.textContent).toContain(
'Todavia no se abrio ningun modal.',
);
confirmButton.click();
fixture.detectChanges();
@@ -193,17 +219,17 @@ describe('ReutilizablesTestPageComponent', () => {
expect(modalServiceStub.openConfirm).toHaveBeenCalledWith({
title: 'Confirmar accion',
content: 'Caso base para verificar apertura, cierre y devolucion de resultado.',
confirmLabel: 'Confirmar'
confirmLabel: 'Confirmar',
});
expect(modalServiceStub.openConfirmDelete).toHaveBeenCalledWith({
title: 'Eliminar producto',
content:
'Esta accion eliminara el producto del catalogo. Podras volver a crearlo manualmente.',
confirmLabel: 'Eliminar'
confirmLabel: 'Eliminar',
});
expect(
element.querySelector('[data-testid="modal-last-result"]')?.textContent
).toContain('Resultado: false');
expect(element.querySelector('[data-testid="modal-last-result"]')?.textContent).toContain(
'Resultado: false',
);
});
it('updates the visible result when the confirm modal resolves to true', async () => {
@@ -213,17 +239,17 @@ describe('ReutilizablesTestPageComponent', () => {
providers: [
{
provide: TenantService,
useValue: createTenantServiceStub(tenant)
useValue: createTenantServiceStub(tenant),
},
{
provide: ToastService,
useValue: createToastServiceStub()
useValue: createToastServiceStub(),
},
{
provide: ModalService,
useValue: modalServiceStub
}
]
useValue: modalServiceStub,
},
],
}).compileComponents();
const fixture = TestBed.createComponent(ReutilizablesTestPageComponent);
@@ -231,16 +257,14 @@ describe('ReutilizablesTestPageComponent', () => {
const element = fixture.nativeElement as HTMLElement;
const confirmButton = Array.from(
element.querySelectorAll('.button-group app-button button')
).find((button) =>
button.textContent?.includes('Abrir confirm modal')
) as HTMLButtonElement;
element.querySelectorAll('.button-group app-button button'),
).find((button) => button.textContent?.includes('Abrir confirm modal')) as HTMLButtonElement;
confirmButton.click();
fixture.detectChanges();
expect(
element.querySelector('[data-testid="modal-last-result"]')?.textContent
).toContain('Resultado: true');
expect(element.querySelector('[data-testid="modal-last-result"]')?.textContent).toContain(
'Resultado: true',
);
});
});

View File

@@ -14,6 +14,16 @@ import { TenantService } from '../../../../core/services/tenant.service';
import { ToastService } from '../../../../core/services/toast.service';
import { StepperComponent } from '../../../../shared/components/stepper/stepper.component';
import { StepComponent } from '../../../../shared/components/stepper/step.component';
import { CarouselComponent } from '../../../../shared/components/carousel/carousel.component';
interface CarouselProductMock {
id: number;
title: string;
originalPrice: number;
discount: number | null;
transferPrice: number;
imageUrl: string;
}
@Component({
selector: 'app-reutilizables-test-page',
@@ -30,6 +40,7 @@ import { StepComponent } from '../../../../shared/components/stepper/step.compon
CartIconComponent,
StepperComponent,
StepComponent,
CarouselComponent,
],
templateUrl: './reutilizables-test-page.component.html',
styleUrl: './reutilizables-test-page.component.scss',
@@ -87,6 +98,62 @@ export class ReutilizablesTestPageComponent {
},
];
protected readonly carouselProducts: CarouselProductMock[] = [
{
id: 1,
title: 'Pantalón scuba negro',
originalPrice: 95000,
discount: 20,
transferPrice: 74800,
imageUrl: '/images/pantalon-scuba.png',
},
{
id: 2,
title: 'Zapatillas running azul',
originalPrice: 120000,
discount: 15,
transferPrice: 98000,
imageUrl: '/images/zapatillas-running.png',
},
{
id: 3,
title: 'Pantalón urbano',
originalPrice: 89000,
discount: 10,
transferPrice: 78500,
imageUrl: '/images/pantalon-scuba.png',
},
{
id: 4,
title: 'Zapatillas deportivas',
originalPrice: 135000,
discount: null,
transferPrice: 128000,
imageUrl: '/images/zapatillas-running.png',
},
{
id: 5,
title: 'Pantalón clásico',
originalPrice: 102000,
discount: 25,
transferPrice: 75000,
imageUrl: '/images/pantalon-scuba.png',
},
{
id: 6,
title: 'Zapatillas training',
originalPrice: 142000,
discount: 12,
transferPrice: 121000,
imageUrl: '/images/zapatillas-running.png',
},
];
protected readonly trackCarouselProduct = (
_index: number,
product: CarouselProductMock,
): number => product.id;
protected readonly testRowProduct = {
title: 'ENTRADA GENERAL',
description:

View File

@@ -0,0 +1,40 @@
<section class="carousel" role="region" [attr.aria-label]="ariaLabel()">
<div
#viewport
class="carousel__viewport"
tabindex="0"
(scroll)="handleScroll()"
(keydown)="handleKeydown($event)"
>
@for (item of items(); track trackBy()($index, item); let index = $index) {
<div class="carousel__item">
<ng-container
[ngTemplateOutlet]="itemTemplate()"
[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>
}
</section>

View File

@@ -0,0 +1,75 @@
:host {
display: block;
min-width: 0;
}
.carousel {
position: relative;
width: 100%;
&__viewport {
display: flex;
width: 100%;
overflow-x: auto;
overscroll-behavior-inline: contain;
scroll-behavior: smooth;
scroll-snap-type: inline mandatory;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
&:focus-visible {
outline: 2px solid var(--color-primary, currentColor);
outline-offset: 2px;
}
}
&__item {
flex: 0 0 auto;
min-width: 0;
scroll-snap-align: start;
}
&__control {
position: absolute;
top: 50%;
display: grid;
width: 2.25rem;
height: 2.25rem;
padding: 0;
color: #fff;
cursor: pointer;
background: rgb(0 0 0 / 55%);
border: 0;
border-radius: 50%;
place-items: center;
transform: translateY(-50%);
&:disabled {
cursor: default;
opacity: 0;
pointer-events: none;
}
&:focus-visible {
outline: 2px solid var(--color-primary, currentColor);
outline-offset: 2px;
}
&--previous {
left: 0.5rem;
}
&--next {
right: 0.5rem;
}
}
}
@media (prefers-reduced-motion: reduce) {
.carousel__viewport {
scroll-behavior: auto;
}
}

View File

@@ -0,0 +1,95 @@
import { Component, TemplateRef, viewChild } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CarouselComponent, CarouselItemContext } from './carousel.component';
interface TestItem {
id: number;
label: string;
}
@Component({
imports: [CarouselComponent],
template: `
<ng-template #itemTemplate let-item let-index="index">
<article [attr.data-index]="index">{{ item.label }}</article>
</ng-template>
<app-carousel
[items]="items"
[itemTemplate]="itemTemplate"
[scrollStep]="120"
ariaLabel="Productos destacados"
/>
`,
})
class TestHostComponent {
readonly items: TestItem[] = [
{ id: 1, label: 'Primero' },
{ id: 2, label: 'Segundo' },
{ id: 3, label: 'Tercero' },
];
readonly carousel = viewChild.required(CarouselComponent<TestItem>);
readonly itemTemplate =
viewChild.required<TemplateRef<CarouselItemContext<TestItem>>>('itemTemplate');
}
describe('CarouselComponent', () => {
let fixture: ComponentFixture<TestHostComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TestHostComponent],
}).compileComponents();
fixture = TestBed.createComponent(TestHostComponent);
fixture.detectChanges();
});
it('renderiza el template provisto para cada elemento', () => {
const articles = fixture.nativeElement.querySelectorAll('article');
expect(articles).toHaveLength(3);
expect(articles[0].textContent.trim()).toBe('Primero');
expect(articles[1].getAttribute('data-index')).toBe('1');
});
it('expone una región accesible y controles con etiquetas', () => {
const region = fixture.nativeElement.querySelector('[role="region"]');
const buttons = fixture.nativeElement.querySelectorAll('button');
expect(region.getAttribute('aria-label')).toBe('Productos destacados');
expect(buttons[0].getAttribute('aria-label')).toBe('Mostrar elementos anteriores');
expect(buttons[1].getAttribute('aria-label')).toBe('Mostrar elementos siguientes');
});
it('desplaza el viewport en ambas direcciones', () => {
const viewport = fixture.nativeElement.querySelector('.carousel__viewport') as HTMLElement;
const scrollBy = vi.fn();
viewport.scrollBy = scrollBy;
fixture.componentInstance.carousel().next();
fixture.componentInstance.carousel().previous();
expect(scrollBy).toHaveBeenNthCalledWith(1, {
left: 120,
behavior: 'smooth',
});
expect(scrollBy).toHaveBeenNthCalledWith(2, {
left: -120,
behavior: 'smooth',
});
});
it('permite navegar con las flechas del teclado', () => {
const viewport = fixture.nativeElement.querySelector('.carousel__viewport') as HTMLElement;
const scrollBy = vi.fn();
viewport.scrollBy = scrollBy;
viewport.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }));
viewport.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }));
expect(scrollBy).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,128 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
DestroyRef,
ElementRef,
PLATFORM_ID,
TemplateRef,
computed,
inject,
input,
signal,
viewChild,
} from '@angular/core';
import { isPlatformBrowser, NgTemplateOutlet } from '@angular/common';
export interface CarouselItemContext<T> {
$implicit: T;
index: number;
}
export type CarouselTrackBy<T> = (index: number, item: T) => unknown;
@Component({
selector: 'app-carousel',
imports: [NgTemplateOutlet],
templateUrl: './carousel.component.html',
styleUrl: './carousel.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CarouselComponent<T> implements AfterViewInit {
private readonly destroyRef = inject(DestroyRef);
private readonly platformId = inject(PLATFORM_ID);
private readonly viewport = viewChild.required<ElementRef<HTMLElement>>('viewport');
readonly items = input.required<readonly T[]>();
readonly itemTemplate = input.required<TemplateRef<CarouselItemContext<T>>>();
readonly trackBy = input<CarouselTrackBy<T>>((index) => index);
readonly scrollStep = input<number | null>(null);
readonly ariaLabel = input('Carrusel');
readonly previousLabel = input('Mostrar elementos anteriores');
readonly nextLabel = input('Mostrar elementos siguientes');
protected readonly canScrollPrevious = signal(false);
protected readonly canScrollNext = signal(false);
protected readonly hasMultipleItems = computed(() => this.items().length > 1);
ngAfterViewInit(): void {
if (!isPlatformBrowser(this.platformId)) {
return;
}
const viewport = this.viewport().nativeElement;
this.refreshNavigation();
const resizeObserver =
typeof ResizeObserver === 'undefined'
? null
: new ResizeObserver(() => this.refreshNavigation());
resizeObserver?.observe(viewport);
const mutationObserver =
typeof MutationObserver === 'undefined'
? null
: new MutationObserver(() => this.refreshNavigation());
mutationObserver?.observe(viewport, { childList: true });
this.destroyRef.onDestroy(() => {
resizeObserver?.disconnect();
mutationObserver?.disconnect();
});
}
previous(): void {
this.scroll(-1);
}
next(): void {
this.scroll(1);
}
scrollTo(index: number): void {
const item = this.viewport().nativeElement.children.item(index) as HTMLElement | null;
item?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'start',
});
}
protected handleScroll(): void {
this.refreshNavigation();
}
protected handleKeydown(event: KeyboardEvent): void {
if (event.key === 'ArrowLeft') {
event.preventDefault();
this.previous();
}
if (event.key === 'ArrowRight') {
event.preventDefault();
this.next();
}
}
private scroll(direction: -1 | 1): void {
const viewport = this.viewport().nativeElement;
const configuredStep = this.scrollStep();
const step =
configuredStep !== null && configuredStep > 0 ? configuredStep : viewport.clientWidth;
viewport.scrollBy({
left: direction * step,
behavior: 'smooth',
});
}
private refreshNavigation(): void {
const viewport = this.viewport().nativeElement;
const maxScrollLeft = Math.max(viewport.scrollWidth - viewport.clientWidth, 0);
const tolerance = 1;
this.canScrollPrevious.set(viewport.scrollLeft > tolerance);
this.canScrollNext.set(viewport.scrollLeft < maxScrollLeft - tolerance);
}
}