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'; /** CIBG procesnavigatie primary-button copy for a non-final step: "Naar stap 2 - Werk". Shared so every wizard's `primaryLabel` reads the same way. */ export const naarStapLabel = (stepNumber: number, stepLabel: string) => $localize`:@@wizard.naarStap:Naar stap ${stepNumber}:nummer: - ${stepLabel}:label:`; /** 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 — CIBG stappenindicator (title merged in) + error * summary + the horizontal
+ the CIBG procesnavigatie button row + 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], // CIBG-GAP EXTENSION: Foutmelding — the vendored build has no error-summary/ // Veldvalidatie list pattern (verified absent from huisstijl.min.css); the // .es-title/.es-list rules below are the hand-rolled surface, see cibg-gaps.mdx. // They render inside a vendored `.feedback-error` alert (app-alert). styles: [ ` .es-title { margin: 0 0 var(--rhc-space-max-sm); } .es-list { margin: 0; padding-inline-start: var(--rhc-space-max-xl); } /* Default link color doesn't meet contrast on the error-alert's light-red surface. */ .es-list a { color: var(--rhc-color-lintblauw-700); } `, ], template: ` @switch (status()) { @case ('editing') { @if (errors().length) { }
* verplichte velden

{{ primaryLabel() }}
@if (canGoBack()) { Terug naar vorige stap }
Annuleren
} @case ('submitting') { {{ submittingLabel() }} } @case ('submitted') { } @case ('failed') { {{ errorMessage() }}
Opnieuw proberen
} } `, }) export class WizardShellComponent { steps = input.required(); current = input.required(); stepTitle = input.required(); /** Overall process name, shown above the step title (e.g. "Herregistratie aanvragen"). */ processName = input(''); 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(); /** A visited step number was clicked in the stepper — back-navigation only. */ goToStep = 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 stepper = viewChild(StepperComponent); private errorSummary = viewChild>('errorSummary'); constructor() { // A11y: move focus to the step title 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.stepper()?.focusTitle())); }); // 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; }); } }