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

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