fix(stepper): add disabled state to prevent navigation and provide visual feedback

This commit is contained in:
ncoronel 2026-08-24 14:07:12 -03:00
parent 7fbdd3844f
commit 8861e42fff
5 changed files with 53 additions and 8 deletions

View File

@ -6,7 +6,11 @@
} @else { } @else {
<div class="checkout-page"> <div class="checkout-page">
<div class="checkout-page__stepper-col"> <div class="checkout-page__stepper-col">
<app-stepper #stepper [initialStepIndex]="checkoutStepIndex()"> <app-stepper
#stepper
[initialStepIndex]="checkoutStepIndex()"
[disabled]="hasSubmittedTransfer()"
>
<app-step label="Datos" [isValid]="isStep1Valid()"> <app-step label="Datos" [isValid]="isStep1Valid()">
<app-checkout-data-step <app-checkout-data-step
[form]="form" [form]="form"

View File

@ -1,25 +1,27 @@
<div class="stepper-container"> <div class="stepper-container">
<!-- Horizontal indicator bar --> <!-- Horizontal indicator bar -->
<div class="stepper-header"> <div class="stepper-header" [class.is-disabled]="disabled()" [attr.aria-disabled]="disabled()">
@for (step of steps(); track step; let i = $index; let last = $last) { @for (step of steps(); track step; let i = $index; let last = $last) {
<div <div
class="stepper-header__item" class="stepper-header__item"
[ngClass]="{ [ngClass]="{
'is-active': currentStepIndex() === i, 'is-active': currentStepIndex() === i,
'is-completed': currentStepIndex() > i 'is-completed': currentStepIndex() > i,
}" }"
> >
<!-- Connecting line before (except first) --> <!-- Connecting line before (except first) -->
@if (i > 0) { @if (i > 0) {
<div class="stepper-header__line" [ngClass]="{ 'is-completed': currentStepIndex() > i - 1 }"></div> <div
class="stepper-header__line"
[ngClass]="{ 'is-completed': currentStepIndex() > i - 1 }"
></div>
} }
<!-- Circle indicator --> <!-- Circle indicator -->
<div <div
class="stepper-header__circle" class="stepper-header__circle"
[attr.aria-label]="step.label()" [attr.aria-label]="step.label()"
[class.is-clickable]="currentStepIndex() > i" [class.is-clickable]="!disabled() && currentStepIndex() > i"
(click)="goToStep(i)" (click)="goToStep(i)"
></div> ></div>
@ -33,5 +35,4 @@
<div class="stepper-body"> <div class="stepper-body">
<ng-content></ng-content> <ng-content></ng-content>
</div> </div>
</div> </div>

View File

@ -13,6 +13,14 @@
margin-bottom: 2rem; margin-bottom: 2rem;
position: relative; position: relative;
&.is-disabled {
opacity: 0.5;
.stepper-header__circle {
cursor: not-allowed;
}
}
&__item { &__item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -6,7 +6,7 @@ import { StepComponent } from './step.component';
@Component({ @Component({
imports: [StepperComponent, StepComponent], imports: [StepperComponent, StepComponent],
template: ` template: `
<app-stepper #stepper> <app-stepper #stepper [disabled]="stepperDisabled()">
<app-step label="Step 1" [isValid]="step1Valid()"> <app-step label="Step 1" [isValid]="step1Valid()">
<div id="content-1">Content 1</div> <div id="content-1">Content 1</div>
</app-step> </app-step>
@ -20,6 +20,7 @@ class TestHostComponent {
@ViewChild('stepper') stepper!: StepperComponent; @ViewChild('stepper') stepper!: StepperComponent;
step1Valid = signal(true); step1Valid = signal(true);
step2Valid = signal(true); step2Valid = signal(true);
stepperDisabled = signal(false);
} }
describe('StepperComponent & StepComponent', () => { describe('StepperComponent & StepComponent', () => {
@ -137,4 +138,28 @@ describe('StepperComponent & StepComponent', () => {
expect(component.stepper.currentStepIndex()).toBe(0); expect(component.stepper.currentStepIndex()).toBe(0);
expect(fixture.nativeElement.querySelector('#content-1')).not.toBeNull(); expect(fixture.nativeElement.querySelector('#content-1')).not.toBeNull();
}); });
it('blocks all navigation and shows visual feedback while disabled', async () => {
const { fixture, component } = await setup();
component.stepperDisabled.set(true);
fixture.detectChanges();
component.stepper.next();
expect(component.stepper.currentStepIndex()).toBe(0);
component.stepperDisabled.set(false);
fixture.detectChanges();
component.stepper.next();
expect(component.stepper.currentStepIndex()).toBe(1);
component.stepperDisabled.set(true);
fixture.detectChanges();
component.stepper.previous();
component.stepper.goToStep(0);
expect(component.stepper.currentStepIndex()).toBe(1);
const header = fixture.nativeElement.querySelector('.stepper-header');
expect(header.classList.contains('is-disabled')).toBe(true);
expect(header.getAttribute('aria-disabled')).toBe('true');
});
}); });

View File

@ -18,9 +18,12 @@ import { NgClass } from '@angular/common';
export class StepperComponent { export class StepperComponent {
readonly steps = contentChildren(StepComponent); readonly steps = contentChildren(StepComponent);
readonly initialStepIndex = input(0); readonly initialStepIndex = input(0);
readonly disabled = input(false);
readonly currentStepIndex = linkedSignal(() => this.initialStepIndex()); readonly currentStepIndex = linkedSignal(() => this.initialStepIndex());
next() { next() {
if (this.disabled()) return;
const currentSteps = this.steps(); const currentSteps = this.steps();
const currentIndex = this.currentStepIndex(); const currentIndex = this.currentStepIndex();
if (currentIndex < currentSteps.length - 1) { if (currentIndex < currentSteps.length - 1) {
@ -32,6 +35,8 @@ export class StepperComponent {
} }
previous() { previous() {
if (this.disabled()) return;
const currentIndex = this.currentStepIndex(); const currentIndex = this.currentStepIndex();
if (currentIndex > 0) { if (currentIndex > 0) {
this.currentStepIndex.set(currentIndex - 1); this.currentStepIndex.set(currentIndex - 1);
@ -39,6 +44,8 @@ export class StepperComponent {
} }
goToStep(index: number) { goToStep(index: number) {
if (this.disabled()) return;
const targetIndex = index; const targetIndex = index;
// Only allow navigating to completed steps or the current one // Only allow navigating to completed steps or the current one
if (targetIndex < this.currentStepIndex()) { if (targetIndex < this.currentStepIndex()) {