Rijkshuisstijl restyle + wizard fixes

Chrome: two-tier Rijksoverheid header (white brand bar + lint-blue
breadcrumb bar, route-driven), dark multi-column footer, white page
surface. Session shown via a shared SESSION_PORT token (keeps shared/
free of the auth context).

Overview ("Mijn overzicht") rebuilt to the NL Design System #392 pattern:
side-nav + "Wat moet ik regelen" task list (derived) + "Mijn registratie"
cards. New shared components: card, task-list, side-nav; pure
tasksFromProfile (+spec).

Wizards: grey form panel, connected numbered stepper, form-field
"(verplicht)" markers + styled description/error, full-width inputs.
Propagated to login, detail, change-request, address-fields.

Bug fixes:
- wizard-shell: add FormsModule so NgForm intercepts submit (wizards now
  advance; no native GET leaking choices into the URL).
- wizard-shell: error-summary links focus the field instead of navigating
  (a fragment href resolved against <base href="/"> reloaded to "/" and
  bounced to login).
- wizard-shell: error-summary focus only on the rising edge, so typing
  while errors are shown no longer scrolls the page up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-27 13:21:54 +02:00
parent d08f3877f7
commit 7a582ae2fa
30 changed files with 677 additions and 149 deletions

View File

@@ -1,4 +1,5 @@
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';
@@ -25,7 +26,7 @@ export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed';
*/
@Component({
selector: 'app-wizard-shell',
imports: [ButtonComponent, AlertComponent, SpinnerComponent, StepperComponent],
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)}
@@ -41,13 +42,13 @@ export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed';
<h3 id="wizard-error-title" class="rhc-heading nl-heading--level-3 es-title">Er ging iets mis met uw invoer</h3>
<ul class="es-list">
@for (e of errors(); track e.id) {
<li><a [href]="'#' + e.id">{{ e.message }}</a></li>
<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">
<form (ngSubmit)="primary.emit()" class="app-form app-form-panel">
<ng-content />
<div class="app-button-row app-button-row--spaced">
@if (canGoBack()) {
@@ -89,6 +90,14 @@ export class WizardShellComponent {
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');
@@ -102,14 +111,18 @@ export class WizardShellComponent {
if (firstStep) { firstStep = false; return; }
untracked(() => queueMicrotask(() => this.stepHeading()?.nativeElement.focus()));
});
// A11y: when validation errors appear (after a failed submit), move focus to
// the error summary so it's announced. The reducer returns a fresh errors
// object per attempt, so resubmitting the same invalid step re-announces.
// 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; return; }
if (has) untracked(() => queueMicrotask(() => this.errorSummary()?.nativeElement.focus()));
if (firstErr) { firstErr = false; hadErrors = has; return; }
if (has && !hadErrors) untracked(() => queueMicrotask(() => this.errorSummary()?.nativeElement.focus()));
hadErrors = has;
});
}
}