Files
atomic-design-poc/src/app/shared/layout/wizard-shell/wizard-shell.component.ts
Edwin van den Houdt 474c040410 Step 2 (i18n): $localize sweep + JA_NEE dedup (M3, M4)
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>
2026-06-27 14:00:10 +02:00

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;
});
}
}