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

@@ -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);
}
}