feat(stepper): implement reusable stepper component with navigation and validation
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
5
src/app/shared/components/stepper/step.component.html
Normal file
5
src/app/shared/components/stepper/step.component.html
Normal file
@@ -0,0 +1,5 @@
|
||||
@if (isActive()) {
|
||||
<div class="step-body">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
}
|
||||
14
src/app/shared/components/stepper/step.component.scss
Normal file
14
src/app/shared/components/stepper/step.component.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
26
src/app/shared/components/stepper/step.component.ts
Normal file
26
src/app/shared/components/stepper/step.component.ts
Normal 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());
|
||||
}
|
||||
43
src/app/shared/components/stepper/stepper.component.html
Normal file
43
src/app/shared/components/stepper/stepper.component.html
Normal 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>
|
||||
111
src/app/shared/components/stepper/stepper.component.scss
Normal file
111
src/app/shared/components/stepper/stepper.component.scss
Normal 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
|
||||
}
|
||||
128
src/app/shared/components/stepper/stepper.component.spec.ts
Normal file
128
src/app/shared/components/stepper/stepper.component.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
41
src/app/shared/components/stepper/stepper.component.ts
Normal file
41
src/app/shared/components/stepper/stepper.component.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user