import {
Component,
ElementRef,
computed,
effect,
input,
output,
untracked,
viewChild,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import { SpinnerComponent } from '@shared/ui/atoms/spinner/spinner.component';
import { StepperComponent } from '@shared/ui/molecules/stepper/stepper.component';
import { whenTag } from '@shared/kernel/fp';
/** 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;
}
/** The wizard shell's lifecycle union. The `Failed` variant carries the localized
message intact, so the shell needs no separate input to say what went wrong. */
export type WizardPhase =
| { tag: 'Editing' }
| { tag: 'Submitting' }
| { tag: 'Submitted' }
| { tag: 'Failed'; message: string };
/**
* 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
}
@case ('Submitting') {
{{ submittingLabel() }}
}
@case ('Submitted') {
}
@case ('Failed') {
{{ failedMessage() }}
}
}
`,
})
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('');
phase = input.required();
primaryLabel = input.required();
canGoBack = input(false);
errors = input([]);
submittingLabel = input($localize`:@@wizard.submitting:Aanvraag wordt verwerkt…`);
/** The `Failed` message, or '' otherwise. `@switch` can't narrow a union in a
template, so the narrowing happens here via the shared `whenTag` helper. */
protected failedMessage = computed(() => whenTag(this.phase(), 'Failed')?.message ?? '');
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;
});
}
}