From 3e98d08d59564629bba24bdc1358fa642d09b251 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 23 Jul 2026 11:47:10 -0300 Subject: [PATCH] feat(carousel): implement product carousel component with mock data and styles --- .../reutilizables-test-page.component.html | 32 ++++ .../reutilizables-test-page.component.scss | 28 +++- .../reutilizables-test-page.component.spec.ts | 138 ++++++++++-------- .../reutilizables-test-page.component.ts | 67 +++++++++ .../carousel/carousel.component.html | 40 +++++ .../carousel/carousel.component.scss | 75 ++++++++++ .../carousel/carousel.component.spec.ts | 95 ++++++++++++ .../components/carousel/carousel.component.ts | 128 ++++++++++++++++ 8 files changed, 542 insertions(+), 61 deletions(-) create mode 100644 src/app/shared/components/carousel/carousel.component.html create mode 100644 src/app/shared/components/carousel/carousel.component.scss create mode 100644 src/app/shared/components/carousel/carousel.component.spec.ts create mode 100644 src/app/shared/components/carousel/carousel.component.ts diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html index f34ff5a..3aadc6c 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.html @@ -574,6 +574,38 @@ +
+
+ Reusable Component +

Carousel de productos

+ +

+ El carousel controla únicamente el desplazamiento. Cada ítem se define externamente con un + template y en este ejemplo renderiza productos con imágenes mockeadas. +

+ + + + + + +
+
+
Reusable Component diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.scss b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.scss index a96c31c..088cf5e 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.scss +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.scss @@ -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); diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.spec.ts b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.spec.ts index 5fc1474..a065f8c 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.spec.ts +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.spec.ts @@ -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', + ); }); }); diff --git a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts index de73780..3e34c67 100644 --- a/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts +++ b/src/app/features/componentes-test/pages/reutilizables-test-page/reutilizables-test-page.component.ts @@ -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: diff --git a/src/app/shared/components/carousel/carousel.component.html b/src/app/shared/components/carousel/carousel.component.html new file mode 100644 index 0000000..a506f35 --- /dev/null +++ b/src/app/shared/components/carousel/carousel.component.html @@ -0,0 +1,40 @@ + diff --git a/src/app/shared/components/carousel/carousel.component.scss b/src/app/shared/components/carousel/carousel.component.scss new file mode 100644 index 0000000..d69a64e --- /dev/null +++ b/src/app/shared/components/carousel/carousel.component.scss @@ -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; + } +} diff --git a/src/app/shared/components/carousel/carousel.component.spec.ts b/src/app/shared/components/carousel/carousel.component.spec.ts new file mode 100644 index 0000000..c30c844 --- /dev/null +++ b/src/app/shared/components/carousel/carousel.component.spec.ts @@ -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: ` + +
{{ item.label }}
+
+ + + `, +}) +class TestHostComponent { + readonly items: TestItem[] = [ + { id: 1, label: 'Primero' }, + { id: 2, label: 'Segundo' }, + { id: 3, label: 'Tercero' }, + ]; + + readonly carousel = viewChild.required(CarouselComponent); + readonly itemTemplate = + viewChild.required>>('itemTemplate'); +} + +describe('CarouselComponent', () => { + let fixture: ComponentFixture; + + 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); + }); +}); diff --git a/src/app/shared/components/carousel/carousel.component.ts b/src/app/shared/components/carousel/carousel.component.ts new file mode 100644 index 0000000..4ce7e2a --- /dev/null +++ b/src/app/shared/components/carousel/carousel.component.ts @@ -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 { + $implicit: T; + index: number; +} + +export type CarouselTrackBy = (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 implements AfterViewInit { + private readonly destroyRef = inject(DestroyRef); + private readonly platformId = inject(PLATFORM_ID); + private readonly viewport = viewChild.required>('viewport'); + + readonly items = input.required(); + readonly itemTemplate = input.required>>(); + readonly trackBy = input>((index) => index); + readonly scrollStep = input(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); + } +}