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 { 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"