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 @@
+
+
+ @for (item of items(); track trackBy()($index, item); let index = $index) {
+
+
+
+ }
+
+
+ @if (hasMultipleItems()) {
+
+
+
+
+
+
+
+ }
+
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);
+ }
+}