feat(stepper): implement reusable stepper component with navigation and validation

This commit is contained in:
2026-07-03 10:10:53 -03:00
parent 625e2fea6c
commit 510c72624b
9 changed files with 464 additions and 1 deletions

View File

@@ -336,6 +336,94 @@
<hr class="section-divider" />
<div class="stepper-showcase">
<h2>Form Stepper</h2>
<p class="text-muted mb-4">
Stepper vertical reutilizable. Cada paso maneja sus propios botones de navegacion. No se puede avanzar si el paso no es valido.
</p>
<app-stepper #demoStepper>
<!-- Paso 1: Siempre valido -->
<app-step label="Informacion Personal" [isValid]="true">
<div class="row g-3">
<div class="col-md-6 input-field">
<label for="stepper-name">Nombre</label>
<app-input id="stepper-name" placeholder="Juan Perez" [(value)]="user" />
</div>
<div class="col-md-6 input-field">
<label for="stepper-email">Email</label>
<app-input id="stepper-email" type="email" placeholder="mail@empresa.com" [(value)]="email" />
</div>
</div>
<div class="d-flex justify-content-end mt-4">
<app-button (click)="demoStepper.next()">
Siguiente <i class="fa-solid fa-arrow-right ms-1"></i>
</app-button>
</div>
</app-step>
<!-- Paso 2: Valido solo si testStep2Valid es true -->
<app-step label="Configuracion de Cuenta" [isValid]="testStep2Valid">
<div class="row g-3">
<div class="col-md-6 input-field">
<label for="stepper-password">Contraseña</label>
<app-input id="stepper-password" type="password-toggle" placeholder="********" [(value)]="password" />
</div>
<div class="col-md-6 input-field">
<label for="stepper-amount">Presupuesto mensual</label>
<app-input id="stepper-amount" type="currency" placeholder="0" [(value)]="amount" />
</div>
</div>
<div class="stepper-validity-control mt-3 d-flex align-items-center gap-3">
<span class="text-muted small">
Simular validez del paso:
</span>
<app-button
[variant]="testStep2Valid ? 'secondary' : 'primary'"
(click)="testStep2Valid = !testStep2Valid"
>
@if (testStep2Valid) {
<i class="fa-solid fa-check me-1"></i> Valido
} @else {
<i class="fa-solid fa-xmark me-1"></i> Invalido — Siguiente bloqueado
}
</app-button>
</div>
<div class="d-flex justify-content-between mt-4">
<app-button variant="secondary" (click)="demoStepper.previous()">
<i class="fa-solid fa-arrow-left me-1"></i> Volver
</app-button>
<app-button (click)="demoStepper.next()" [disabled]="!testStep2Valid">
Siguiente <i class="fa-solid fa-arrow-right ms-1"></i>
</app-button>
</div>
</app-step>
<!-- Paso 3: Revision final, siempre valido -->
<app-step label="Revision y Confirmacion" [isValid]="true">
<div class="p-3 rounded bg-light">
<p class="mb-1"><strong>Nombre:</strong> {{ user }}</p>
<p class="mb-1"><strong>Email:</strong> {{ email }}</p>
<p class="mb-0"><strong>Presupuesto:</strong> {{ amount }}</p>
</div>
<div class="d-flex justify-content-between mt-4">
<app-button variant="secondary" (click)="demoStepper.previous()">
<i class="fa-solid fa-arrow-left me-1"></i> Volver
</app-button>
<app-button (click)="triggerToast('success')">
<i class="fa-solid fa-check me-1"></i> Confirmar
</app-button>
</div>
</app-step>
</app-stepper>
</div>
<hr class="section-divider" />
<div class="cart-showcase">
<div class="cart-showcase__header">
<div>

View File

@@ -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';

View File

@@ -0,0 +1,5 @@
@if (isActive()) {
<div class="step-body">
<ng-content></ng-content>
</div>
}

View File

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

View File

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

View File

@@ -0,0 +1,43 @@
<div class="stepper-container">
<!-- Horizontal indicator bar -->
<div class="stepper-header">
@for (step of steps(); track step; let i = $index; let last = $last) {
<div
class="stepper-header__item"
[ngClass]="{
'is-active': currentStepIndex() === i,
'is-completed': currentStepIndex() > i
}"
>
<!-- Connecting line before (except first) -->
@if (i > 0) {
<div class="stepper-header__line" [ngClass]="{ 'is-completed': currentStepIndex() > i - 1 }"></div>
}
<!-- Circle indicator -->
<button
class="stepper-header__circle"
[attr.aria-label]="step.label()"
[disabled]="currentStepIndex() <= i"
(click)="goToStep(i)"
>
@if (currentStepIndex() > i) {
<i class="fa-solid fa-check"></i>
} @else {
<span>{{ i + 1 }}</span>
}
</button>
<!-- Label -->
<span class="stepper-header__label">{{ step.label() }}</span>
</div>
}
</div>
<!-- Step content panel: only renders the active step's projected content -->
<div class="stepper-body">
<ng-content></ng-content>
</div>
</div>

View File

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

View File

@@ -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: `
<app-stepper #stepper>
<app-step label="Step 1" [isValid]="step1Valid()">
<div id="content-1">Content 1</div>
</app-step>
<app-step label="Step 2" [isValid]="step2Valid()">
<div id="content-2">Content 2</div>
</app-step>
</app-stepper>
`
})
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();
});
});

View File

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