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,14 @@
import { InjectionToken, Signal } from '@angular/core';
/**
* A shared seam for the chrome to show "who is logged in" + log out, WITHOUT
* shared/ depending on the auth context (the import-direction rule forbids that).
* Auth provides this token at the app root (see app.config.ts); the shared header
* injects it. SessionStore satisfies this shape structurally.
*/
export interface SessionPort {
readonly session: Signal<{ naam: string } | null>;
logout(): void;
}
export const SESSION_PORT = new InjectionToken<SessionPort>('SESSION_PORT');

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

View File

@@ -0,0 +1,32 @@
import { Component, input } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
/** Molecule: a content card. Standardises the repeated card surface (white,
subtle border, rounded, padded) so pages compose cards instead of hand-rolling
`<div class="rhc-card">`. Optional heading; the rest is projected. */
@Component({
selector: 'app-card',
imports: [HeadingComponent],
styles: [`
:host{display:block;block-size:100%}
.card{
background:var(--rhc-color-wit);
border:var(--rhc-border-width-sm) solid var(--rhc-color-border-subtle);
border-radius:var(--rhc-border-radius-md);
padding:var(--rhc-space-max-xl);
block-size:100%;
box-sizing:border-box;
}
.card > * + *{margin-block-start:var(--rhc-space-max-md)}
`],
template: `
<section class="card">
@if (heading()) { <app-heading [level]="level()">{{ heading() }}</app-heading> }
<ng-content />
</section>
`,
})
export class CardComponent {
heading = input<string>();
level = input<1 | 2 | 3 | 4 | 5>(3);
}

View File

@@ -0,0 +1,20 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { CardComponent } from './card.component';
const meta: Meta<CardComponent> = {
title: 'Molecules/Card',
component: CardComponent,
render: (args) => ({
props: args,
template: `
<app-card [heading]="heading" [level]="level">
<p class="rhc-paragraph">Een kaart groepeert samenhangende inhoud op een schone, omkaderde vlak.</p>
</app-card>`,
}),
args: { heading: 'Persoonsgegevens (BRP)', level: 3 },
};
export default meta;
type Story = StoryObj<CardComponent>;
export const Default: Story = {};
export const ZonderKop: Story = { args: { heading: undefined } };

View File

@@ -1,18 +1,27 @@
import { Component, input } from '@angular/core';
import { Component, booleanAttribute, input } from '@angular/core';
/** Molecule: form field = label + projected control + optional error/description.
Reused by both the login form and the change-request form. */
@Component({
selector: 'app-form-field',
styles: [`
:host{display:block}
.label{display:block;font-weight:var(--rhc-text-font-weight-semi-bold);margin-block-end:var(--rhc-space-max-sm)}
.req{font-weight:var(--rhc-text-font-weight-regular);color:var(--rhc-color-foreground-subtle)}
.desc{color:var(--rhc-color-foreground-subtle);font-size:var(--rhc-text-font-size-sm);margin-block-end:var(--rhc-space-max-sm)}
.error{color:var(--rhc-color-foreground-default);font-weight:var(--rhc-text-font-weight-semi-bold);margin-block-start:var(--rhc-space-max-sm);border-inline-start:var(--rhc-border-width-md) solid var(--rhc-color-rood-500);padding-inline-start:var(--rhc-space-max-md)}
`],
template: `
<div class="rhc-form-field utrecht-form-field" [class.utrecht-form-field--invalid]="!!error()">
<label class="utrecht-form-label" [id]="fieldId() + '-label'" [for]="fieldId()">{{ label() }}</label>
<label class="utrecht-form-label label" [id]="fieldId() + '-label'" [for]="fieldId()">
{{ label() }}@if (required()) { <span class="req">(verplicht)</span> }
</label>
@if (description()) {
<div class="utrecht-form-field-description" [id]="fieldId() + '-desc'">{{ description() }}</div>
<div class="utrecht-form-field-description desc" [id]="fieldId() + '-desc'">{{ description() }}</div>
}
<ng-content />
@if (error()) {
<div class="utrecht-form-field-error-message" [id]="fieldId() + '-error'" role="alert">{{ error() }}</div>
<div class="utrecht-form-field-error-message error" [id]="fieldId() + '-error'" role="alert">{{ error() }}</div>
}
</div>
`,
@@ -22,4 +31,5 @@ export class FormFieldComponent {
fieldId = input.required<string>();
description = input<string>();
error = input<string>();
required = input(false, { transform: booleanAttribute });
}

View File

@@ -8,18 +8,33 @@ import { Component, input } from '@angular/core';
styles: [`
:host{display:block}
.sr-only{position:absolute;inline-size:1px;block-size:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.steps{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-xl);list-style:none;margin:0;padding:0}
.step{display:flex;align-items:center;gap:var(--rhc-space-max-md);color:var(--rhc-color-foreground-subtle)}
.steps{display:flex;list-style:none;margin:0;padding:0}
.step{
flex:1 1 0;min-inline-size:0;position:relative;
display:flex;flex-direction:column;align-items:center;gap:var(--rhc-space-max-sm);
text-align:center;color:var(--rhc-color-foreground-subtle);
}
/* connector line to the previous step, drawn behind the circles */
.step::before{
content:'';position:absolute;z-index:0;
inset-block-start:calc(var(--rhc-space-max-4xl) / 2);
inset-inline-start:-50%;inline-size:100%;block-size:var(--rhc-border-width-md);
background:var(--rhc-color-cool-grey-300);
}
.step:first-child::before{display:none}
.step--done::before,.step--current::before{background:var(--rhc-color-lintblauw-500)}
.num{
display:grid;place-items:center;
position:relative;z-index:1;display:grid;place-items:center;
inline-size:var(--rhc-space-max-4xl);block-size:var(--rhc-space-max-4xl);
border-radius:var(--rhc-border-radius-round);
border:var(--rhc-space-max-xs) solid var(--rhc-color-cool-grey-400);
border:var(--rhc-border-width-md) solid var(--rhc-color-cool-grey-300);
background:var(--rhc-color-wit);
font-weight:var(--rhc-text-font-weight-bold);
}
.step--done .num{background:var(--rhc-color-lintblauw-500);border-color:var(--rhc-color-lintblauw-500);color:var(--rhc-color-wit)}
.label{font-size:var(--rhc-text-font-size-sm);padding-inline:var(--rhc-space-max-sm)}
.step--done .num{background:var(--rhc-color-lintblauw-500);border-color:var(--rhc-color-lintblauw-500);color:var(--rhc-color-foreground-on-primary)}
.step--current{color:var(--rhc-color-foreground-default)}
.step--current .num{background:var(--rhc-color-lintblauw-700);border-color:var(--rhc-color-lintblauw-700);color:var(--rhc-color-wit)}
.step--current .num{background:var(--rhc-color-lintblauw-700);border-color:var(--rhc-color-lintblauw-700);color:var(--rhc-color-foreground-on-primary)}
.step--current .label{font-weight:var(--rhc-text-font-weight-semi-bold)}
`],
template: `

View File

@@ -0,0 +1,51 @@
import { Component, input } from '@angular/core';
import { LinkComponent } from '@shared/ui/link/link.component';
/** Presentational task shape — what "Wat moet ik regelen" renders. Domain-free so
shared/ stays independent of any context (a context's task type that has these
fields is structurally assignable). */
export interface TaskItem {
readonly title: string;
readonly description: string;
readonly to: string;
readonly actionLabel: string;
}
/** Molecule: the "Wat moet ik regelen" action list (NL Design System "Mijn
omgeving" pattern). Each task is a titled row with a call to action. */
@Component({
selector: 'app-task-list',
imports: [LinkComponent],
styles: [`
:host{display:block}
.tasks{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:var(--rhc-space-max-md)}
.task{
display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-md) var(--rhc-space-max-xl);
align-items:center;justify-content:space-between;
background:var(--rhc-color-wit);
border:var(--rhc-border-width-sm) solid var(--rhc-color-border-subtle);
border-inline-start:var(--rhc-border-width-md) solid var(--rhc-color-lintblauw-700);
border-radius:var(--rhc-border-radius-md);
padding:var(--rhc-space-max-lg) var(--rhc-space-max-xl);
}
.title{margin:0 0 var(--rhc-space-max-xs);font-weight:var(--rhc-text-font-weight-semi-bold)}
.desc{margin:0;color:var(--rhc-color-foreground-subtle);font-size:var(--rhc-text-font-size-sm)}
.cta{flex:0 0 auto}
`],
template: `
<ul class="tasks">
@for (t of tasks(); track t.title) {
<li class="task">
<div>
<p class="title">{{ t.title }}</p>
<p class="desc">{{ t.description }}</p>
</div>
<span class="cta"><app-link [to]="t.to">{{ t.actionLabel }} →</app-link></span>
</li>
}
</ul>
`,
})
export class TaskListComponent {
tasks = input.required<readonly TaskItem[]>();
}

View File

@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideRouter } from '@angular/router';
import { TaskListComponent } from './task-list.component';
const meta: Meta<TaskListComponent> = {
title: 'Molecules/Task List',
component: TaskListComponent,
decorators: [applicationConfig({ providers: [provideRouter([])] })],
render: (args) => ({ props: args, template: `<app-task-list [tasks]="tasks" />` }),
};
export default meta;
type Story = StoryObj<TaskListComponent>;
export const Default: Story = {
args: {
tasks: [
{ title: 'Vraag uw herregistratie aan', description: 'Verleng uw registratie vóór 31 december 2026.', to: '/herregistratie', actionLabel: 'Herregistratie aanvragen' },
{ title: 'Controleer uw adresgegevens', description: 'Uw adres is langer dan een jaar niet bevestigd.', to: '/registratie', actionLabel: 'Bekijk uw gegevens' },
],
},
};

View File

@@ -4,6 +4,10 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
/** Atom: text input. Utrecht textbox wired up as a form control (ngModel/reactive). */
@Component({
selector: 'app-text-input',
styles: [`
:host{display:block}
input{inline-size:100%;box-sizing:border-box}
`],
template: `
<input
class="utrecht-textbox rhc-text-input"