import { Component, ElementRef, effect, input, output, untracked, viewChild } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ButtonComponent } from '@shared/ui/button/button.component'; import { AlertComponent } from '@shared/ui/alert/alert.component'; import { SpinnerComponent } from '@shared/ui/spinner/spinner.component'; import { StepperComponent } from '@shared/ui/stepper/stepper.component'; /** A flat validation error pointing at a field: `id` matches the field's anchor. */ export interface WizardError { readonly id: string; readonly message: string; } export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed'; /** * Template: the canonical shell every wizard renders into, so they cannot drift. * It owns the consistent outline — stepper + focusable step heading + error * summary + the
+ the Back/Next/Cancel action bar + the submitting/ * submitted/failed states — and the a11y focus management. * * Presentational and unidirectional: all state stays in the wizard container * (the Elm-style store). Inputs flow down; the container reacts to the outputs * and dispatches messages. The step's own fields are projected as the default * slot; the success screen is projected via [wizardSuccess]. */ @Component({ selector: 'app-wizard-shell', imports: [FormsModule, ButtonComponent, AlertComponent, SpinnerComponent, StepperComponent], styles: [` .es-title{margin:0 0 var(--rhc-space-max-sm)} .es-list{margin:0;padding-inline-start:var(--rhc-space-max-xl)} `], template: ` @switch (status()) { @case ('editing') {

{{ stepTitle() }}

@if (errors().length) { }
@if (canGoBack()) { Vorige } {{ primaryLabel() }} Annuleren
} @case ('submitting') { {{ submittingLabel() }} } @case ('submitted') { } @case ('failed') { {{ errorMessage() }}
Opnieuw proberen
} } `, }) export class WizardShellComponent { steps = input.required(); current = input.required(); stepTitle = input.required(); status = input.required(); primaryLabel = input.required(); canGoBack = input(false); errors = input([]); errorMessage = input(''); submittingLabel = input($localize`:@@wizard.submitting:Aanvraag wordt verwerkt…`); primary = output(); back = output(); cancel = output(); retry = output(); /** Error-summary link: focus the field instead of letting the browser navigate. A fragment href resolves against , not the current route, so a real navigation would reload to "/" and bounce to login. */ protected goToField(ev: Event, id: string) { ev.preventDefault(); document.getElementById(id)?.focus(); // focus() scrolls the input into view } private stepHeading = viewChild>('stepHeading'); private errorSummary = viewChild>('errorSummary'); constructor() { // A11y: move focus to the step heading when the step changes (skip first run // so we don't grab focus on initial load). Tracks current(), which is value- // stable across keystrokes, so typing never steals focus. let firstStep = true; effect(() => { this.current(); if (firstStep) { firstStep = false; return; } untracked(() => queueMicrotask(() => this.stepHeading()?.nativeElement.focus())); }); // A11y: when validation errors first appear (after a failed submit), move // focus to the error summary so it's announced. Only on the rising edge // (none → some): typing rebuilds the errors array each keystroke, and // re-focusing then would scroll the page up mid-edit. The summary keeps // role="alert", so content changes are still announced without the jump. let firstErr = true; let hadErrors = false; effect(() => { const has = this.errors().length > 0; if (firstErr) { firstErr = false; hadErrors = has; return; } if (has && !hadErrors) untracked(() => queueMicrotask(() => this.errorSummary()?.nativeElement.focus())); hadErrors = has; }); } }