import { NgTemplateOutlet } from '@angular/common'; import { afterRenderEffect, booleanAttribute, ChangeDetectionStrategy, Component, computed, contentChildren, ElementRef, inject, input, output, signal, TemplateRef, viewChild, } from '@angular/core'; import { ModeColorService } from '@africanies/africanies-theme'; import { AfricaniesUiI18n } from '../i18n/africanies-ui-i18n'; import { StepDefDirective } from './step-def.directive'; import { StepDefinition } from './step-definition'; function prefersReducedMotion(): boolean { return ( typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches ); } /** * Multi-step wizard for form flows (e.g. shipment creation). * * Distinct from a status *timeline*: this component navigates wizard steps, * it does not display shipment delivery progress. * * Inputs: `steps`, `activeIndex`, and `linear` (default `true`). In linear * mode, header clicks only activate the current or a previous step; advance * with Next. Non-linear mode allows jumping to any step via its header. * * When `animateSteps` is true (default), the step body fades/slides via the * Web Animations API (skipped when the user prefers reduced motion). */ @Component({ selector: 'africanies-stepper', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, imports: [NgTemplateOutlet], host: { class: 'block min-w-0' }, styles: ` .africanies-step-rail { scrollbar-width: thin; } .africanies-step-body { min-width: 0; overflow-x: clip; /* 4 px breathing room so any remaining external focus rings are not clipped */ padding-inline: 4px; margin-inline: -4px; } .africanies-step-pane { min-width: 0; will-change: opacity, transform; } `, template: `

{{ stepOfLabel() }} · {{ steps()[activeIndex()]?.label }}

    @for (step of steps(); track step.key; let i = $index) {
  1. @if (i < steps().length - 1) { }
  2. }
@if (activeTemplate(); as tpl) {
}
`, }) /** * Multi-step wizard for form flows (e.g. shipment creation). */ export class StepperComponent { protected readonly modeColor = inject(ModeColorService); private readonly i18n = inject(AfricaniesUiI18n); readonly steps = input.required(); readonly activeIndex = input.required(); readonly linear = input(true, { transform: booleanAttribute }); readonly animateSteps = input(true, { transform: booleanAttribute }); readonly activeIndexChange = output(); private readonly stepDefs = contentChildren(StepDefDirective); private readonly stepPane = viewChild>('stepPane'); private readonly animatedIndex = signal(null); protected readonly stepOfLabel = computed(() => this.i18n.tParams( 'africaniesUi.stepper.stepOf', 'Step {{current}} of {{total}}', { current: this.activeIndex() + 1, total: this.steps().length, }, ), ); private readonly stepTemplateMap = computed(() => { const map = new Map>(); for (const def of this.stepDefs()) { map.set(def.africaniesStepDef(), def.template); } return map; }); protected readonly activeTemplate = computed(() => { const step = this.steps()[this.activeIndex()]; if (!step) { return null; } return this.stepTemplateMap().get(step.key) ?? null; }); protected readonly stepContext = computed(() => { const step = this.steps()[this.activeIndex()]; return { $implicit: step?.key ?? '' }; }); protected readonly progressPercent = computed(() => { const total = this.steps().length; if (total <= 0) { return 0; } return ((this.activeIndex() + 1) / total) * 100; }); constructor() { afterRenderEffect(() => { if (!this.animateSteps()) { return; } const index = this.activeIndex(); const el = this.stepPane()?.nativeElement; if (!el) { return; } const previous = this.animatedIndex(); if (previous === index) { return; } this.animatedIndex.set(index); if (previous === null || prefersReducedMotion()) { return; } const forward = index > previous; el.animate( [ { opacity: 0, transform: `translateX(${forward ? '1rem' : '-1rem'})`, }, { opacity: 1, transform: 'translateX(0)' }, ], { duration: 280, easing: 'cubic-bezier(0.22, 1, 0.36, 1)', fill: 'both', }, ); }); } protected headerButtonClass(index: number): string { const active = index === this.activeIndex(); const past = index < this.activeIndex(); if (active) { return 'bg-background-welcome text-ink dark:bg-white/10 dark:text-white'; } if (past) { return 'cursor-pointer text-ink hover:bg-background-welcome dark:text-white dark:hover:bg-white/10'; } return 'opacity-40 text-neutral-600 dark:text-neutral-400'; } protected stepBadgeClass(index: number): string { const base = 'inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full border text-body-sm font-medium'; if (index <= this.activeIndex()) { const c = this.modeColor.classes(); return `${base} ${c.border} ${c.bg} text-white`; } return `${base} border-control bg-surface text-neutral-600 dark:border-white/20 dark:bg-ink-950 dark:text-neutral-400`; } protected canActivate(index: number): boolean { if (index === this.activeIndex()) { return true; } if (!this.linear()) { return true; } return index < this.activeIndex(); } protected onHeaderClick(index: number): void { if (index === this.activeIndex()) { return; } if (!this.canActivate(index)) { return; } this.activeIndexChange.emit(index); } }