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 6668f5c..65afcf5 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 @@ -336,6 +336,94 @@
+
+

Form Stepper

+

+ Stepper vertical reutilizable. Cada paso maneja sus propios botones de navegacion. No se puede avanzar si el paso no es valido. +

+ + + + + +
+
+ + +
+
+ + +
+
+
+ + Siguiente + +
+
+ + + +
+
+ + +
+
+ + +
+
+ +
+ + Simular validez del paso: + + + @if (testStep2Valid) { + Valido + } @else { + Invalido — Siguiente bloqueado + } + +
+ +
+ + Volver + + + Siguiente + +
+
+ + + +
+

Nombre: {{ user }}

+

Email: {{ email }}

+

Presupuesto: {{ amount }}

+
+
+ + Volver + + + Confirmar + +
+
+ +
+
+ +
+
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 8e037cd..026e994 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 @@ -13,6 +13,8 @@ import { StoreSectionComponent } from '../../../../shared/components/store-secti import { ModalService } from '../../../../core/services/modal.service'; 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'; @Component({ selector: 'app-reutilizables-test-page', @@ -24,7 +26,9 @@ import { ToastService } from '../../../../core/services/toast.service'; ProductCardComponent, StoreSectionComponent, IconButtonComponent, - CartIconComponent + CartIconComponent, + StepperComponent, + StepComponent ], templateUrl: './reutilizables-test-page.component.html', styleUrl: './reutilizables-test-page.component.scss' @@ -33,6 +37,9 @@ export class ReutilizablesTestPageComponent { private readonly tenantService = inject(TenantService); private readonly toastService = inject(ToastService); private readonly modalService = inject(ModalService); + + protected testStep1Valid = true; + protected testStep2Valid = false; protected readonly tenant = this.tenantService.tenant; protected textValue = 'Auriculares'; protected numberValue = '24'; diff --git a/src/app/shared/components/stepper/step.component.html b/src/app/shared/components/stepper/step.component.html new file mode 100644 index 0000000..7239f9c --- /dev/null +++ b/src/app/shared/components/stepper/step.component.html @@ -0,0 +1,5 @@ +@if (isActive()) { +
+ +
+} diff --git a/src/app/shared/components/stepper/step.component.scss b/src/app/shared/components/stepper/step.component.scss new file mode 100644 index 0000000..ba46fa9 --- /dev/null +++ b/src/app/shared/components/stepper/step.component.scss @@ -0,0 +1,14 @@ +.step-body { + animation: fadeIn 0.25s ease-out forwards; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/src/app/shared/components/stepper/step.component.ts b/src/app/shared/components/stepper/step.component.ts new file mode 100644 index 0000000..8bf992a --- /dev/null +++ b/src/app/shared/components/stepper/step.component.ts @@ -0,0 +1,26 @@ +import { ChangeDetectionStrategy, Component, computed, forwardRef, inject, input } from '@angular/core'; +import { StepperComponent } from './stepper.component'; + +@Component({ + selector: 'app-step', + imports: [], + templateUrl: './step.component.html', + styleUrl: './step.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class StepComponent { + stepper = inject(forwardRef(() => StepperComponent)); + + readonly label = input.required(); + readonly isValid = input(true); + + // We compute the index by looking at the parent's steps array + index = computed(() => { + const steps = this.stepper.steps(); + return steps.findIndex((step: StepComponent) => step === this); + }); + + isActive = computed(() => this.stepper.currentStepIndex() === this.index()); + isCompleted = computed(() => this.stepper.currentStepIndex() > this.index()); + isLast = computed(() => this.stepper.steps().length - 1 === this.index()); +} diff --git a/src/app/shared/components/stepper/stepper.component.html b/src/app/shared/components/stepper/stepper.component.html new file mode 100644 index 0000000..81f2490 --- /dev/null +++ b/src/app/shared/components/stepper/stepper.component.html @@ -0,0 +1,43 @@ +
+ + +
+ @for (step of steps(); track step; let i = $index; let last = $last) { +
+ + @if (i > 0) { +
+ } + + + + + + {{ step.label() }} +
+ } +
+ + +
+ +
+ +
diff --git a/src/app/shared/components/stepper/stepper.component.scss b/src/app/shared/components/stepper/stepper.component.scss new file mode 100644 index 0000000..d0f5ddb --- /dev/null +++ b/src/app/shared/components/stepper/stepper.component.scss @@ -0,0 +1,111 @@ +.stepper-container { + display: flex; + flex-direction: column; + width: 100%; +} + +/* ─── Header (indicator bar) ─── */ +.stepper-header { + display: flex; + align-items: flex-start; + justify-content: center; + gap: 0; + margin-bottom: 2rem; + position: relative; + + &__item { + display: flex; + flex-direction: column; + align-items: center; + flex: 1; + position: relative; + } + + &__line { + position: absolute; + top: 19px; // center of the 40px circle + right: 50%; + left: -50%; + height: 2px; + background-color: var(--bs-gray-300); + transition: background-color 0.35s ease; + z-index: 0; + + &.is-completed { + background-color: var(--bs-primary); + } + } + + &__circle { + position: relative; + z-index: 1; + width: 40px; + height: 40px; + border-radius: 50%; + border: 2px solid var(--bs-gray-300); + background-color: var(--bs-gray-100); + color: var(--bs-gray-500); + font-weight: 700; + font-size: 0.875rem; + display: flex; + align-items: center; + justify-content: center; + cursor: default; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + outline: none; + + &:not(:disabled) { + cursor: pointer; + + &:hover { + border-color: var(--bs-primary); + color: var(--bs-primary); + } + } + } + + &__label { + margin-top: 8px; + font-size: 0.8125rem; + font-weight: 500; + color: var(--bs-gray-500); + text-align: center; + transition: color 0.3s ease; + max-width: 120px; + line-height: 1.3; + } + + // Active step + &__item.is-active { + .stepper-header__circle { + background-color: var(--bs-primary); + border-color: var(--bs-primary); + color: white; + box-shadow: 0 4px 12px rgba(var(--bs-primary-rgb), 0.35); + } + + .stepper-header__label { + color: var(--bs-primary); + font-weight: 600; + } + } + + // Completed step + &__item.is-completed { + .stepper-header__circle { + background-color: var(--bs-primary); + border-color: var(--bs-primary); + color: white; + } + + .stepper-header__label { + color: var(--bs-body-color); + } + } +} + +/* ─── Body (content area) ─── */ +.stepper-body { + // Hide all step wrappers visually — step.component.html + // controls visibility with *ngIf on the inner content +} diff --git a/src/app/shared/components/stepper/stepper.component.spec.ts b/src/app/shared/components/stepper/stepper.component.spec.ts new file mode 100644 index 0000000..e0081b4 --- /dev/null +++ b/src/app/shared/components/stepper/stepper.component.spec.ts @@ -0,0 +1,128 @@ +import { Component, signal, ViewChild } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { StepperComponent } from './stepper.component'; +import { StepComponent } from './step.component'; + +@Component({ + imports: [StepperComponent, StepComponent], + template: ` + + +
Content 1
+
+ +
Content 2
+
+
+ ` +}) +class TestHostComponent { + @ViewChild('stepper') stepper!: StepperComponent; + step1Valid = signal(true); + step2Valid = signal(true); +} + +describe('StepperComponent & StepComponent', () => { + async function setup() { + await TestBed.configureTestingModule({ + imports: [TestHostComponent, StepperComponent, StepComponent] + }).compileComponents(); + + const fixture = TestBed.createComponent(TestHostComponent); + fixture.detectChanges(); + return { fixture, component: fixture.componentInstance }; + } + + it('renders step labels in the indicator bar', async () => { + const { fixture } = await setup(); + const labels = fixture.nativeElement.querySelectorAll('.stepper-header__label'); + expect(labels.length).toBe(2); + expect(labels[0].textContent.trim()).toBe('Step 1'); + expect(labels[1].textContent.trim()).toBe('Step 2'); + }); + + it('activates the first step by default and shows its content', async () => { + const { fixture } = await setup(); + const content1 = fixture.nativeElement.querySelector('#content-1'); + const content2 = fixture.nativeElement.querySelector('#content-2'); + + expect(content1).not.toBeNull(); + expect(content2).toBeNull(); + }); + + it('advances to step 2 when next() is called and current step is valid', async () => { + const { fixture, component } = await setup(); + + component.stepper.next(); + fixture.detectChanges(); + + const content1 = fixture.nativeElement.querySelector('#content-1'); + const content2 = fixture.nativeElement.querySelector('#content-2'); + + expect(content1).toBeNull(); + expect(content2).not.toBeNull(); + expect(component.stepper.currentStepIndex()).toBe(1); + }); + + it('does not advance to step 2 when next() is called if current step is invalid', async () => { + const { fixture, component } = await setup(); + component.step1Valid.set(false); + fixture.detectChanges(); + + component.stepper.next(); + fixture.detectChanges(); + + const content1 = fixture.nativeElement.querySelector('#content-1'); + const content2 = fixture.nativeElement.querySelector('#content-2'); + + expect(content1).not.toBeNull(); + expect(content2).toBeNull(); + expect(component.stepper.currentStepIndex()).toBe(0); + }); + + it('goes back to step 1 when previous() is called', async () => { + const { fixture, component } = await setup(); + + component.stepper.next(); + fixture.detectChanges(); + expect(component.stepper.currentStepIndex()).toBe(1); + + component.stepper.previous(); + fixture.detectChanges(); + + expect(component.stepper.currentStepIndex()).toBe(0); + expect(fixture.nativeElement.querySelector('#content-1')).not.toBeNull(); + }); + + it('marks the first circle as active by default', async () => { + const { fixture } = await setup(); + const items = fixture.nativeElement.querySelectorAll('.stepper-header__item'); + expect(items[0].classList.contains('is-active')).toBe(true); + expect(items[1].classList.contains('is-active')).toBe(false); + }); + + it('marks step 1 as completed and step 2 as active after advancing', async () => { + const { fixture, component } = await setup(); + + component.stepper.next(); + fixture.detectChanges(); + + const items = fixture.nativeElement.querySelectorAll('.stepper-header__item'); + expect(items[0].classList.contains('is-completed')).toBe(true); + expect(items[1].classList.contains('is-active')).toBe(true); + }); + + it('allows navigating back to a completed step via goToStep()', async () => { + const { fixture, component } = await setup(); + + component.stepper.next(); + fixture.detectChanges(); + expect(component.stepper.currentStepIndex()).toBe(1); + + component.stepper.goToStep(0); + fixture.detectChanges(); + + expect(component.stepper.currentStepIndex()).toBe(0); + expect(fixture.nativeElement.querySelector('#content-1')).not.toBeNull(); + }); +}); diff --git a/src/app/shared/components/stepper/stepper.component.ts b/src/app/shared/components/stepper/stepper.component.ts new file mode 100644 index 0000000..08ee4c5 --- /dev/null +++ b/src/app/shared/components/stepper/stepper.component.ts @@ -0,0 +1,41 @@ +import { ChangeDetectionStrategy, Component, contentChildren, signal } from '@angular/core'; +import { StepComponent } from './step.component'; +import { NgClass } from '@angular/common'; + +@Component({ + selector: 'app-stepper', + imports: [NgClass], + templateUrl: './stepper.component.html', + styleUrl: './stepper.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class StepperComponent { + readonly steps = contentChildren(StepComponent); + readonly currentStepIndex = signal(0); + + next() { + const currentSteps = this.steps(); + const currentIndex = this.currentStepIndex(); + if (currentIndex < currentSteps.length - 1) { + const currentStep = currentSteps[currentIndex]; + if (currentStep.isValid()) { + this.currentStepIndex.set(currentIndex + 1); + } + } + } + + previous() { + const currentIndex = this.currentStepIndex(); + if (currentIndex > 0) { + this.currentStepIndex.set(currentIndex - 1); + } + } + + goToStep(index: number) { + const targetIndex = index; + // Only allow navigating to completed steps or the current one + if (targetIndex < this.currentStepIndex()) { + this.currentStepIndex.set(targetIndex); + } + } +}