@solopx/spx-ui-kit
v0.2.1
Published
Angular component library with layout container, stepper, and theming support
Maintainers
Readme
spx-ui-kit
A reusable Angular component library for consistent, accessible layouts.
Features
- Layout Container with fixed header and scrollable content
- Theming and responsive design
- Accessibility support
- Storybook documentation
Usage
Import the SpxLayoutContainerComponent and use the <spx-layout-container> selector in your templates.
Development
- Run
npm startto launch Storybook - Run
npm testto execute unit tests
See CONTRIBUTING.md for more details.
Stepper Documentation
Overview
The SpxStepper is a powerful CDK-style Angular component for creating multi-step workflows with advanced validation, navigation control, and responsive design. It follows Angular's modern architecture using signals and standalone components.
Installation
npm install @solopx/spx-ui-kitBasic Usage
Simple Stepper
import { Component, ViewChild } from '@angular/core';
import { SpxStepperComponent, SpxStepComponent, SpxStepPanelComponent } from '@solopx/spx-ui-kit';
@Component({
selector: 'app-my-stepper',
imports: [SpxStepperComponent, SpxStepComponent, SpxStepPanelComponent],
template: `
<spx-stepper #stepper>
<spx-step value="step1" label="Basic Information">
<spx-step-panel value="step1">
<h3>Step 1: Enter your details</h3>
<p>Content for step 1...</p>
<button (click)="stepper.validateAndNext()">Next</button>
</spx-step-panel>
</spx-step>
<spx-step value="step2" label="Confirmation">
<spx-step-panel value="step2">
<h3>Step 2: Confirm</h3>
<p>Content for step 2...</p>
<button (click)="stepper.validateAndPrevious()">Previous</button>
</spx-step-panel>
</spx-step>
</spx-stepper>
`,
})
export class MyStepperComponent {
@ViewChild('stepper') stepper!: SpxStepperComponent;
}Components
SpxStepperComponent
Main container component that manages step navigation and validation.
Inputs
| Input | Type | Default | Description |
| ------------------- | --------- | ------- | ------------------------------------------------------- |
| activeStep | string | '' | Initial active step value |
| linear | boolean | false | Enable linear mode (users must complete steps in order) |
| allowPreviewSteps | boolean | false | Allow clicking step headers to preview future steps |
Outputs
| Output | Type | Description |
| ------------------ | ----------------------- | ---------------------------------------------------- |
| activeStepChange | string | Emits when active step changes |
| stepChange | StepChangeEvent | Emits detailed step change information |
| beforeStepChange | BeforeStepChangeEvent | Emits before step change (can be cancelled) |
| validateStep | ValidateStepEvent | Emits for step validation with prevention capability |
SpxStepComponent
Defines individual step configuration.
Inputs
| Input | Type | Default | Description |
| ----------- | --------- | -------- | ------------------------------ |
| value | string | Required | Unique identifier for the step |
| label | string | '' | Display label for step header |
| completed | boolean | false | Mark step as completed |
| disabled | boolean | false | Disable step interaction |
SpxStepPanelComponent
Container for step content with automatic visibility management.
Inputs
| Input | Type | Description |
| ------- | -------- | --------------------------------------------------------- |
| value | string | Step value this panel belongs to (must match parent step) |
Advanced Usage
Linear Mode with Validation
@Component({
template: `
<spx-stepper
#stepper
[linear]="true"
[allowPreviewSteps]="false"
(validateStep)="onValidateStep($event)"
>
<spx-step value="personal" label="Personal Info">
<spx-step-panel value="personal">
<form #personalForm="ngForm">
<input [(ngModel)]="personalData.name" name="name" required />
<input [(ngModel)]="personalData.email" name="email" required />
</form>
<button [disabled]="!stepper.canProceedToNextStep()" (click)="stepper.validateAndNext()">
Next Step
</button>
</spx-step-panel>
</spx-step>
<spx-step value="address" label="Address">
<spx-step-panel value="address">
<!-- Address form content -->
</spx-step-panel>
</spx-step>
</spx-stepper>
`,
})
export class LinearStepperComponent {
personalData = { name: '', email: '' };
onValidateStep(event: ValidateStepEvent): void {
if (event.step === 'personal' && event.direction === 'next') {
if (!this.personalData.name || !this.personalData.email) {
event.preventDefault(); // Prevent navigation
alert('Please fill in all required fields');
}
}
}
}Dynamic Workflow Steps
@Component({
template: `
<spx-stepper #stepper (validateStep)="onValidateStep($event)">
<!-- Initial step -->
<spx-step value="workflow-type" label="Choose Workflow">
<spx-step-panel value="workflow-type">
<label>
<input
type="radio"
[(ngModel)]="workflowType"
value="simple"
(change)="onWorkflowChange()"
/>
Simple Workflow
</label>
<label>
<input
type="radio"
[(ngModel)]="workflowType"
value="advanced"
(change)="onWorkflowChange()"
/>
Advanced Workflow
</label>
</spx-step-panel>
</spx-step>
<!-- Conditional steps based on workflow type -->
@if (workflowType === 'simple') {
<spx-step value="simple-config" label="Simple Configuration">
<spx-step-panel value="simple-config">
<!-- Simple workflow content -->
</spx-step-panel>
</spx-step>
} @if (workflowType === 'advanced') {
<spx-step value="advanced-config" label="Advanced Configuration">
<spx-step-panel value="advanced-config">
<!-- Advanced workflow content -->
</spx-step-panel>
</spx-step>
<spx-step value="advanced-settings" label="Advanced Settings">
<spx-step-panel value="advanced-settings">
<!-- Additional settings -->
</spx-step-panel>
</spx-step>
}
</spx-stepper>
`,
})
export class DynamicWorkflowComponent {
workflowType: 'simple' | 'advanced' | null = null;
onWorkflowChange(): void {
// Invalidate steps after workflow selection when changing
if (this.stepper) {
this.stepper.invalidateStepsAfter('workflow-type');
}
}
}Event System
ValidateStepEvent
Emitted when step navigation is attempted. Use preventDefault() to stop navigation.
interface ValidateStepEvent {
step: string; // Current step being validated
direction: 'next' | 'previous'; // Navigation direction
preventDefault(): void; // Call to prevent navigation
}
onValidateStep(event: ValidateStepEvent): void {
console.log(`Validating step: ${event.step}, direction: ${event.direction}`);
// Custom validation logic
if (event.step === 'payment' && !this.isPaymentValid()) {
event.preventDefault();
this.showPaymentError();
}
}StepChangeEvent
Emitted after successful step change.
interface StepChangeEvent {
previousStep: string | null;
currentStep: string;
stepIndex: number;
}
onStepChange(event: StepChangeEvent): void {
console.log(`Changed from ${event.previousStep} to ${event.currentStep}`);
// Track analytics
this.analytics.trackStepProgress(event.currentStep, event.stepIndex);
}BeforeStepChangeEvent
Emitted before step change occurs. Can be cancelled.
interface BeforeStepChangeEvent {
from: string | null;
to: string;
preventDefault(): void;
}
onBeforeStepChange(event: BeforeStepChangeEvent): void {
// Show confirmation for certain transitions
if (event.from === 'draft' && event.to === 'published') {
const confirmed = confirm('Are you sure you want to publish?');
if (!confirmed) {
event.preventDefault();
}
}
}Programmatic Navigation
ViewChild Access Pattern
export class MyComponent {
@ViewChild('stepper') stepper!: SpxStepperComponent;
// Navigation methods
goToNextStep(): void {
this.stepper.validateAndNext();
}
goToPreviousStep(): void {
this.stepper.validateAndPrevious();
}
goToSpecificStep(stepValue: string): void {
this.stepper.setActiveStep(stepValue);
}
// State checking
canProceed(): boolean {
return this.stepper.canProceedToNextStep();
}
getCurrentStep(): string {
return this.stepper.activeStepValue();
}
isStepCompleted(stepValue: string): boolean {
return this.stepper.isStepCompleted(stepValue);
}
// Step management
markStepComplete(stepValue: string): void {
this.stepper.markStepCompleted(stepValue);
}
resetStepper(): void {
this.stepper.reset();
}
}Available Methods
| Method | Parameters | Return Type | Description |
| ----------------------------- | ---------- | ----------- | -------------------------------------------------- |
| validateAndNext() | - | boolean | Validate current step and move to next |
| validateAndPrevious() | - | boolean | Validate current step and move to previous |
| setActiveStep(value) | string | boolean | Set specific step as active |
| reset() | - | void | Reset stepper to initial state |
| canProceedToNextStep() | - | boolean | Check if next step navigation is allowed |
| canNavigateToStep(value) | string | boolean | Check if navigation to specific step is allowed |
| isStepCompleted(value) | string | boolean | Check if step is marked as completed |
| markStepCompleted(value) | string | void | Mark step as completed |
| markStepVisited(value) | string | void | Mark step as visited |
| invalidateStepsAfter(value) | string | void | Reset completion status for steps after given step |
Computed Signals
| Signal | Type | Description |
| ------------------- | -------------------- | ------------------------------ |
| activeStepValue() | string | Currently active step value |
| activeStepIndex() | number | Index of currently active step |
| stepsArray() | SpxStepComponent[] | Array of all step components |
| firstStepValue() | string | Value of first step |
| lastStepValue() | string | Value of last step |
Styling & Theming
CSS Classes
The stepper uses BEM-style CSS classes for consistent styling:
// Stepper container
.stepper {
display: flex;
// ... stepper styles
}
// Individual step headers
.stepper__item {
// Base step header styles
&.active {
// Active step styles
}
&.completed {
// Completed step styles
}
&.disabled {
// Disabled step styles
}
}
// Step content panels
.spx-step-panel {
// Panel container styles
}Custom Theming
// Override stepper variables
:root {
--stepper-primary-color: #007bff;
--stepper-completed-color: #28a745;
--stepper-disabled-color: #6c757d;
}
// Custom step header styling
.stepper__item {
border-color: var(--stepper-primary-color);
&.completed {
background-color: var(--stepper-completed-color);
}
}Best Practices
1. Form Integration
// Use reactive forms for better validation
@Component({
template: `
<spx-stepper (validateStep)="onValidateStep($event)">
<spx-step value="form-step" label="Form Data">
<spx-step-panel value="form-step">
<form [formGroup]="myForm">
<input formControlName="email" type="email" />
<input formControlName="password" type="password" />
</form>
</spx-step-panel>
</spx-step>
</spx-stepper>
`,
})
export class FormStepperComponent {
myForm = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
onValidateStep(event: ValidateStepEvent): void {
if (event.step === 'form-step' && event.direction === 'next') {
if (this.myForm.invalid) {
event.preventDefault();
this.myForm.markAllAsTouched();
}
}
}
}2. Async Operations
async onValidateStep(event: ValidateStepEvent): Promise<void> {
if (event.step === 'save-step' && event.direction === 'next') {
try {
event.preventDefault(); // Prevent immediate navigation
const result = await this.apiService.saveData(this.formData);
if (result.success) {
this.stepper.markStepCompleted('save-step');
this.stepper.validateAndNext(); // Now proceed
} else {
alert('Save failed. Please try again.');
}
} catch (error) {
console.error('Save error:', error);
}
}
}3. State Management
// Use services for complex state management
@Injectable()
export class StepperStateService {
private state = signal({
currentStep: 'step1',
formData: {},
completedSteps: new Set<string>(),
});
readonly currentStep = computed(() => this.state().currentStep);
readonly isStepCompleted = (step: string) =>
computed(() => this.state().completedSteps.has(step));
updateStep(step: string): void {
this.state.update((state) => ({
...state,
currentStep: step,
}));
}
completeStep(step: string): void {
this.state.update((state) => ({
...state,
completedSteps: new Set([...state.completedSteps, step]),
}));
}
}Accessibility
The stepper includes built-in accessibility features:
- Keyboard navigation with arrow keys and Enter/Space
- ARIA labels and roles for screen readers
- Focus management during step transitions
- High contrast support for step states
Additional ARIA Support
<spx-stepper role="tablist" aria-label="Multi-step form navigation">
<spx-step value="step1" label="Personal Information" [attr.aria-describedby]="'step1-desc'">
<spx-step-panel value="step1" role="tabpanel" [attr.aria-labelledby]="'step1-header'">
<div id="step1-desc">Enter your personal details</div>
<!-- Content -->
</spx-step-panel>
</spx-step>
</spx-stepper>Testing
E2E Testing with Playwright
import { test, expect } from '@playwright/test';
test('stepper navigation works correctly', async ({ page }) => {
await page.goto('/stepper-demo');
// Check initial state
await expect(page.locator('[data-testid="step-header-step1"]')).toHaveClass(/active/);
// Navigate to next step
await page.locator('[data-testid="step1-next-btn"]').click();
await expect(page.locator('[data-testid="step-header-step2"]')).toHaveClass(/active/);
// Test validation prevention
await page.locator('[data-testid="step2-next-btn"]').click();
// Should stay on step2 if validation fails
await expect(page.locator('[data-testid="step-header-step2"]')).toHaveClass(/active/);
});Unit Testing
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SpxStepperComponent } from '@solopx/spx-ui-kit';
describe('SpxStepperComponent', () => {
let component: SpxStepperComponent;
let fixture: ComponentFixture<SpxStepperComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [SpxStepperComponent],
});
fixture = TestBed.createComponent(SpxStepperComponent);
component = fixture.componentInstance;
});
it('should navigate to next step when valid', () => {
// Setup test steps
component.setActiveStep('step1');
// Test navigation
const result = component.validateAndNext();
expect(result).toBe(true);
expect(component.activeStepValue()).toBe('step2');
});
});Troubleshooting
Common Issues
- Steps not appearing: Ensure step values are unique and step panels have matching values
- Navigation not working: Check that ViewChild reference is properly initialized
- Validation not preventing navigation: Make sure to call
event.preventDefault()in validation handler - Form state lost: Use proper form management and avoid removing elements from DOM
Debug Mode
Enable debug logging:
// Add to your component
ngAfterViewInit(): void {
// Enable stepper debugging
(this.stepper as any)._debug = true;
}Migration Guide
From version 0.1.x to 0.2.x
- ✅ No breaking changes
- ✨ New features: Enhanced validation system, outlet directive
- 🔧 Improved: Better form state management, accessibility features
For detailed examples and live demos, visit our Storybook documentation.
