Wrap every user-facing Dutch string in Angular's first-party i18n — `i18n`/ `i18n-<attr>` in templates, `$localize` in TS (value-objects, machines, commands, label constants, shared-component defaults). Source locale stays nl; a second locale is now a translation file, not a code change. - M3: ~145 strings localized with stable @@ ids across registratie, herregistratie, auth, shared/ui, shared/layout. Skipped: showcase, debug-state, scenario interceptor, generated client, specs/stories, raw status enum tags, internal parse* diagnostics. - M4: single shared JA_NEE (localized labels) in radio-group; both wizard copies removed. Gate green: lint, check:tokens, build, test 77/77. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
129 lines
5.6 KiB
TypeScript
129 lines
5.6 KiB
TypeScript
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 <form> + 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') {
|
|
<app-stepper class="app-section" [steps]="steps()" [current]="current()" />
|
|
<h2 #stepHeading tabindex="-1" class="rhc-heading nl-heading--level-2 app-section">{{ stepTitle() }}</h2>
|
|
@if (errors().length) {
|
|
<div #errorSummary tabindex="-1" role="alert" aria-labelledby="wizard-error-title" class="app-section">
|
|
<app-alert type="error">
|
|
<h3 id="wizard-error-title" class="rhc-heading nl-heading--level-3 es-title" i18n="@@wizard.errorTitle">Er ging iets mis met uw invoer</h3>
|
|
<ul class="es-list">
|
|
@for (e of errors(); track e.id) {
|
|
<li><a [href]="'#' + e.id" (click)="goToField($event, e.id)">{{ e.message }}</a></li>
|
|
}
|
|
</ul>
|
|
</app-alert>
|
|
</div>
|
|
}
|
|
<form (ngSubmit)="primary.emit()" class="app-form app-form-panel">
|
|
<ng-content />
|
|
<div class="app-button-row app-button-row--spaced">
|
|
@if (canGoBack()) {
|
|
<app-button type="button" variant="secondary" (click)="back.emit()" i18n="@@wizard.vorige">Vorige</app-button>
|
|
}
|
|
<app-button type="submit" variant="primary">{{ primaryLabel() }}</app-button>
|
|
<app-button type="button" variant="subtle" (click)="cancel.emit()" i18n="@@wizard.annuleren">Annuleren</app-button>
|
|
</div>
|
|
</form>
|
|
}
|
|
@case ('submitting') {
|
|
<app-spinner /> <span>{{ submittingLabel() }}</span>
|
|
}
|
|
@case ('submitted') {
|
|
<ng-content select="[wizardSuccess]" />
|
|
}
|
|
@case ('failed') {
|
|
<app-alert type="error">{{ errorMessage() }}</app-alert>
|
|
<div class="app-section">
|
|
<app-button variant="secondary" (click)="retry.emit()" i18n="@@wizard.opnieuwProberen">Opnieuw proberen</app-button>
|
|
</div>
|
|
}
|
|
}
|
|
`,
|
|
})
|
|
export class WizardShellComponent {
|
|
steps = input.required<string[]>();
|
|
current = input.required<number>();
|
|
stepTitle = input.required<string>();
|
|
status = input.required<WizardStatus>();
|
|
primaryLabel = input.required<string>();
|
|
canGoBack = input(false);
|
|
errors = input<readonly WizardError[]>([]);
|
|
errorMessage = input('');
|
|
submittingLabel = input($localize`:@@wizard.submitting:Aanvraag wordt verwerkt…`);
|
|
|
|
primary = output<void>();
|
|
back = output<void>();
|
|
cancel = output<void>();
|
|
retry = output<void>();
|
|
|
|
/** Error-summary link: focus the field instead of letting the browser navigate.
|
|
A fragment href resolves against <base href="/">, 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<ElementRef<HTMLElement>>('stepHeading');
|
|
private errorSummary = viewChild<ElementRef<HTMLElement>>('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;
|
|
});
|
|
}
|
|
}
|