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

@@ -0,0 +1,32 @@
import { BreadcrumbItem } from './breadcrumb.component';
/** Route → breadcrumb label + parent. The app has a small fixed route set
(see app.routes.ts), so a static map is enough — no per-page wiring.
ponytail: static map, not a breadcrumb service; revisit if routes go dynamic. */
interface Crumb { label: string; parent?: string }
const ROUTES: Record<string, Crumb> = {
'/dashboard': { label: 'Mijn overzicht' },
'/registratie': { label: 'Mijn gegevens', parent: '/dashboard' },
'/registreren': { label: 'Inschrijven', parent: '/dashboard' },
'/herregistratie': { label: 'Herregistratie', parent: '/dashboard' },
'/intake': { label: 'Herregistratie-intake', parent: '/dashboard' },
'/concepts': { label: 'Functionele patronen', parent: '/dashboard' },
};
/** Build the breadcrumb trail for a router url (query/fragment stripped).
Returns [] for unknown routes (e.g. /login) so the bar can hide itself. */
export function trailFor(url: string): BreadcrumbItem[] {
const path = url.split(/[?#]/)[0];
const trail: BreadcrumbItem[] = [];
let cursor: string | undefined = path;
while (cursor) {
const node: Crumb | undefined = ROUTES[cursor];
if (!node) break;
trail.unshift({ label: node.label, link: cursor });
cursor = node.parent;
}
// The current (last) page is not a link.
if (trail.length) delete trail[trail.length - 1].link;
return trail;
}

View File

@@ -14,19 +14,27 @@ export interface BreadcrumbItem {
imports: [RouterLink],
styles: [`
:host{display:block}
.list{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-md);align-items:center;list-style:none;margin:0;padding:0}
.list{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-md);align-items:center;list-style:none;margin:0;padding:0;font-size:var(--rhc-text-font-size-sm)}
.crumb{display:inline-flex;align-items:center;gap:var(--rhc-space-max-md)}
a{color:var(--rhc-color-foreground-link);text-decoration:underline}
a:hover{color:var(--rhc-color-foreground-link-hover)}
.current{color:var(--rhc-color-foreground-subtle)}
.sep{color:var(--rhc-color-foreground-subtle)}
/* Inverse: on the blue header bar, everything is white. */
:host(.inverse) a,
:host(.inverse) .current,
:host(.inverse) .sep { color: var(--rhc-color-foreground-on-primary); }
`],
template: `
<nav class="rhc-breadcrumb-nav utrecht-breadcrumb-nav" aria-label="Kruimelpad">
<ol class="utrecht-breadcrumb-nav__list list">
@for (item of items(); track item.label; let last = $last) {
<li class="utrecht-breadcrumb-nav__item">
@for (item of items(); track item.label; let last = $last; let first = $first) {
<li class="utrecht-breadcrumb-nav__item crumb">
@if (!first) { <span class="sep" aria-hidden="true"></span> }
@if (item.link && !last) {
<a class="utrecht-breadcrumb-nav__link rhc-link nl-link" [routerLink]="item.link">{{ item.label }}</a>
<span class="sep" aria-hidden="true">/</span>
<a [routerLink]="item.link">{{ item.label }}</a>
} @else {
<span class="utrecht-breadcrumb-nav__link rhc-breadcrumb-nav__link--current" aria-current="page">{{ item.label }}</span>
<span class="current" aria-current="page">{{ item.label }}</span>
}
</li>
}

View File

@@ -1,27 +1,24 @@
import { Component, input } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { LinkComponent } from '@shared/ui/link/link.component';
import { BreadcrumbComponent, BreadcrumbItem } from '@shared/layout/breadcrumb/breadcrumb.component';
/** Template: standard page body — optional breadcrumb, optional back-link, a
heading, optional intro, and projected content. Rendered inside the persistent
ShellComponent via the router outlet, so it owns only the content (not chrome). */
/** Template: standard page body — optional back-link, a heading, optional intro,
and projected content. The breadcrumb lives in the site header (blue bar), so
it's not repeated here. Rendered inside the persistent ShellComponent via the
router outlet, so it owns only the content (not chrome). */
@Component({
selector: 'app-page-shell',
imports: [HeadingComponent, LinkComponent, BreadcrumbComponent],
imports: [HeadingComponent, LinkComponent],
styles: [`
:host{display:block}
.body--narrow{max-inline-size:var(--app-form-narrow)}
.crumb{margin-block-end:var(--rhc-space-max-xl)}
.intro{margin-block-end:var(--rhc-space-max-2xl)}
.back{margin:0 0 var(--rhc-space-max-lg)}
.intro{margin-block:var(--rhc-space-max-md) var(--rhc-space-max-2xl);max-inline-size:42rem;color:var(--rhc-color-foreground-subtle)}
`],
template: `
<div [class.body--narrow]="width() === 'narrow'">
@if (breadcrumb()) {
<app-breadcrumb class="crumb" [items]="breadcrumb()!" />
}
@if (backLink()) {
<p><app-link [to]="backLink()!">← {{ backLabel() }}</app-link></p>
<p class="back"><app-link [to]="backLink()!">← {{ backLabel() }}</app-link></p>
}
<app-heading [level]="1">{{ heading() }}</app-heading>
@if (intro()) {
@@ -37,5 +34,4 @@ export class PageShellComponent {
backLink = input<string>();
backLabel = input('Terug naar overzicht');
width = input<'default' | 'narrow'>('default');
breadcrumb = input<BreadcrumbItem[]>();
}

View File

@@ -0,0 +1,50 @@
import { Component, input } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
export interface NavItem {
readonly label: string;
readonly to: string;
}
/** Organism: vertical side navigation for the "Mijn omgeving" portal. The active
route is marked with `routerLinkActive`. Domain-free — the caller supplies the
items (English-named shared chrome). */
@Component({
selector: 'app-side-nav',
imports: [RouterLink, RouterLinkActive],
styles: [`
:host{display:block}
.list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:var(--rhc-space-max-xs)}
.item{
display:block;
padding:var(--rhc-space-max-md) var(--rhc-space-max-lg);
color:var(--rhc-color-foreground-default);
text-decoration:none;
border-radius:var(--rhc-border-radius-sm);
border-inline-start:var(--rhc-border-width-md) solid transparent;
}
.item:hover{background:var(--rhc-color-cool-grey-100)}
.item.active{
background:var(--rhc-color-lintblauw-100);
border-inline-start-color:var(--rhc-color-lintblauw-700);
color:var(--rhc-color-lintblauw-700);
font-weight:var(--rhc-text-font-weight-semi-bold);
}
`],
template: `
<nav [attr.aria-label]="ariaLabel()">
<ul class="list">
@for (item of items(); track item.to) {
<li>
<a class="item" [routerLink]="item.to" routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: true }">{{ item.label }}</a>
</li>
}
</ul>
</nav>
`,
})
export class SideNavComponent {
items = input.required<NavItem[]>();
ariaLabel = input('Mijn omgeving');
}

View File

@@ -0,0 +1,27 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideRouter } from '@angular/router';
import { SideNavComponent } from './side-nav.component';
const meta: Meta<SideNavComponent> = {
title: 'Layout/Side Nav',
component: SideNavComponent,
decorators: [applicationConfig({ providers: [provideRouter([])] })],
render: (args) => ({
props: args,
template: `<div style="max-inline-size:15rem"><app-side-nav [items]="items" /></div>`,
}),
};
export default meta;
type Story = StoryObj<SideNavComponent>;
export const Default: Story = {
args: {
items: [
{ label: 'Overzicht', to: '/dashboard' },
{ label: 'Mijn gegevens', to: '/registratie' },
{ label: 'Herregistratie', to: '/herregistratie' },
{ label: 'Inschrijven', to: '/registreren' },
],
},
};

View File

@@ -1,20 +1,39 @@
import { Component } from '@angular/core';
/** Organism: site footer. */
/** Organism: Rijksoverheid-style site footer — dark-blue, with the
"De Rijksoverheid. Voor Nederland." tagline, responsible-ministry attribution,
and a small "Over deze site" link column. ponytail: links point at the real
rijksoverheid.nl pages, not a fabricated dead-link forest. */
@Component({
selector: 'app-site-footer',
styles: [`
:host{display:block}
.bar{background:var(--rhc-color-donkerblauw-700);color:var(--rhc-color-wit);margin-block-start:var(--rhc-space-max-5xl);inline-size:100%}
.inner{max-inline-size:var(--app-content-max);margin-inline:auto;padding:var(--rhc-space-max-2xl);display:flex;gap:var(--rhc-space-max-3xl);flex-wrap:wrap;box-sizing:border-box}
.end{margin-inline-start:auto}
.bar{background:var(--rhc-color-donkerblauw-700);color:var(--rhc-color-foreground-on-primary);margin-block-start:var(--rhc-space-max-5xl);inline-size:100%}
.inner{max-inline-size:var(--app-content-max);margin-inline:auto;padding:var(--rhc-space-max-3xl) var(--rhc-space-max-2xl);box-sizing:border-box;display:flex;gap:var(--rhc-space-max-3xl);flex-wrap:wrap;justify-content:space-between}
.tagline{font-style:italic;font-weight:var(--rhc-text-font-weight-semi-bold);font-size:var(--rhc-text-font-size-lg);max-inline-size:18rem}
.ministry{margin-block-start:var(--rhc-space-max-md);font-size:var(--rhc-text-font-size-sm);color:var(--rhc-color-foreground-on-primary)}
.col h2{margin:0 0 var(--rhc-space-max-md);font-size:var(--rhc-text-font-size-sm);font-weight:var(--rhc-text-font-weight-bold);text-transform:uppercase;letter-spacing:.04em}
.links{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:var(--rhc-space-max-sm);font-size:var(--rhc-text-font-size-sm)}
.links a{color:var(--rhc-color-foreground-on-primary);text-decoration:none}
.links a:hover{text-decoration:underline}
.meta{inline-size:100%;border-block-start:var(--rhc-border-width-sm) solid var(--rhc-color-lintblauw-600);margin-block-start:var(--rhc-space-max-xl);padding-block-start:var(--rhc-space-max-lg);font-size:var(--rhc-text-font-size-sm);opacity:.85}
`],
template: `
<footer class="utrecht-page-footer bar">
<div class="inner">
<span>BIG-register</span>
<span>CIBG — Ministerie van Volksgezondheid, Welzijn en Sport</span>
<span class="end">Demo / POC — geen echte gegevens</span>
<div>
<div class="tagline">De Rijksoverheid. Voor Nederland.</div>
<div class="ministry">CIBG — Ministerie van Volksgezondheid, Welzijn en Sport</div>
</div>
<nav class="col" aria-label="Over deze site">
<h2>Over deze site</h2>
<ul class="links">
<li><a href="https://www.rijksoverheid.nl/privacy" rel="noopener" target="_blank">Privacy</a></li>
<li><a href="https://www.rijksoverheid.nl/cookies" rel="noopener" target="_blank">Cookies</a></li>
<li><a href="https://www.rijksoverheid.nl/toegankelijkheid" rel="noopener" target="_blank">Toegankelijkheid</a></li>
</ul>
</nav>
<div class="meta">Demo / POC — geen echte gegevens.</div>
</div>
</footer>
`,

View File

@@ -1,37 +1,75 @@
import { Component, input } from '@angular/core';
import { RouterLink } from '@angular/router';
import { Component, computed, inject, input } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router, RouterLink } from '@angular/router';
import { filter, map } from 'rxjs/operators';
import { SESSION_PORT } from '@shared/application/session.port';
import { BreadcrumbComponent } from '@shared/layout/breadcrumb/breadcrumb.component';
import { trailFor } from '@shared/layout/breadcrumb/breadcrumb-trail';
/** Organism: site header with Rijksoverheid-style wordmark + title.
ponytail: text wordmark instead of the licensed Rijksoverheid logo. */
/** Organism: Rijksoverheid-style site header. Two tiers, like rijksoverheid.nl:
a white brand bar (wordmark + session/logout) over a lint-blue bar carrying
the breadcrumb. ponytail: text wordmark, not the licensed Rijksoverheid logo;
no search box (no search feature yet). */
@Component({
selector: 'app-site-header',
imports: [RouterLink],
// :host display:block so the full-bleed bar fills the flex column (custom
// elements default to display:inline, which collapsed the bar to its content).
imports: [RouterLink, BreadcrumbComponent],
styles: [`
:host{display:block}
.bar{background:var(--rhc-color-lintblauw-700);color:var(--rhc-color-wit);inline-size:100%}
.inner{display:flex;align-items:center;gap:var(--rhc-space-max-xl);max-inline-size:var(--app-content-max);margin-inline:auto;padding:var(--rhc-space-max-xl) var(--rhc-space-max-2xl);box-sizing:border-box}
.brand{display:flex;align-items:center;gap:var(--rhc-space-max-lg);color:inherit;text-decoration:none}
.mark{display:inline-block;inline-size:var(--rhc-space-max-md);block-size:var(--rhc-space-max-4xl);background:var(--rhc-color-wit)}
/* Tier 1 — white brand bar */
.brandbar{background:var(--rhc-color-wit);border-block-end:var(--rhc-border-width-sm) solid var(--rhc-color-border-subtle);inline-size:100%}
.brandbar .inner{display:flex;align-items:center;gap:var(--rhc-space-max-xl);max-inline-size:var(--app-content-max);margin-inline:auto;padding:var(--rhc-space-max-lg) var(--rhc-space-max-2xl);box-sizing:border-box}
.brand{display:flex;align-items:center;gap:var(--rhc-space-max-lg);color:var(--rhc-color-foreground-default);text-decoration:none}
.mark{display:inline-block;inline-size:var(--rhc-space-max-md);block-size:var(--rhc-space-max-4xl);background:var(--rhc-color-lintblauw-700)}
.name{font-weight:var(--rhc-text-font-weight-bold);line-height:var(--rhc-text-line-height-sm)}
.sub{font-weight:var(--rhc-text-font-weight-regular);font-size:var(--rhc-text-font-size-sm)}
.portal{margin-inline-start:auto;font-weight:var(--rhc-text-font-weight-semi-bold)}
.sub{font-weight:var(--rhc-text-font-weight-regular);font-size:var(--rhc-text-font-size-sm);color:var(--rhc-color-foreground-subtle)}
.session{margin-inline-start:auto;display:flex;align-items:center;gap:var(--rhc-space-max-lg);font-size:var(--rhc-text-font-size-sm)}
.user{color:var(--rhc-color-foreground-subtle)}
.logout{background:none;border:0;padding:0;cursor:pointer;color:var(--rhc-color-foreground-link);text-decoration:underline;font:inherit}
.logout:hover{color:var(--rhc-color-foreground-link-hover)}
/* Tier 2 — blue breadcrumb bar */
.navbar{background:var(--rhc-color-lintblauw-700);color:var(--rhc-color-foreground-on-primary);inline-size:100%}
.navbar .inner{max-inline-size:var(--app-content-max);margin-inline:auto;padding:var(--rhc-space-max-md) var(--rhc-space-max-2xl);box-sizing:border-box}
`],
template: `
<header class="utrecht-page-header bar">
<div class="brandbar">
<div class="inner">
<a routerLink="/dashboard" class="brand">
<span aria-hidden="true" class="mark"></span>
<span class="name">
Rijksoverheid<br><span class="sub">BIG-register</span>
</span>
<span class="name">Rijksoverheid<br><span class="sub">BIG-register</span></span>
</a>
<span class="portal">{{ subtitle() }}</span>
<span class="portal sub">{{ subtitle() }}</span>
@if (session(); as s) {
<span class="session">
<span class="user">Ingelogd als {{ s.naam }}</span>
<button type="button" class="logout" (click)="logout()">Uitloggen</button>
</span>
}
</div>
</header>
</div>
@if (trail().length) {
<div class="navbar">
<div class="inner">
<app-breadcrumb class="inverse" [items]="trail()" />
</div>
</div>
}
`,
})
export class SiteHeaderComponent {
subtitle = input('Mijn omgeving');
private router = inject(Router);
private sessionPort = inject(SESSION_PORT, { optional: true });
readonly session = computed(() => this.sessionPort?.session() ?? null);
private url = toSignal(
this.router.events.pipe(filter((e) => e instanceof NavigationEnd), map(() => this.router.url)),
{ initialValue: this.router.url },
);
protected trail = computed(() => trailFor(this.url()));
logout() {
this.sessionPort?.logout();
this.router.navigate(['/login']);
}
}

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