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

@@ -7,6 +7,8 @@ import localeNl from '@angular/common/locales/nl';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { scenarioInterceptor } from '@shared/infrastructure/scenario.interceptor'; import { scenarioInterceptor } from '@shared/infrastructure/scenario.interceptor';
import { provideApiClient } from '@shared/infrastructure/api-client.provider'; import { provideApiClient } from '@shared/infrastructure/api-client.provider';
import { SESSION_PORT } from '@shared/application/session.port';
import { SessionStore } from '@auth/application/session.store';
registerLocaleData(localeNl); registerLocaleData(localeNl);
@@ -18,6 +20,7 @@ export const appConfig: ApplicationConfig = {
// a query param could otherwise force errors on the live app. // a query param could otherwise force errors on the live app.
provideHttpClient(withInterceptors(isDevMode() ? [scenarioInterceptor] : [])), provideHttpClient(withInterceptors(isDevMode() ? [scenarioInterceptor] : [])),
provideApiClient(), provideApiClient(),
{ provide: SESSION_PORT, useExisting: SessionStore },
{ provide: LOCALE_ID, useValue: 'nl' }, { provide: LOCALE_ID, useValue: 'nl' },
] ]
}; };

View File

@@ -9,18 +9,16 @@ import { ButtonComponent } from '@shared/ui/button/button.component';
selector: 'app-login-form', selector: 'app-login-form',
imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent], imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent],
template: ` template: `
<form (ngSubmit)="submitted.emit(bsn)"> <form (ngSubmit)="submitted.emit(bsn)" class="app-form app-form-panel">
<app-form-field label="BSN" fieldId="bsn" description="9 cijfers (demo: vul iets in)"> <app-form-field label="BSN" fieldId="bsn" required description="9 cijfers (demo: vul iets in)">
<app-text-input inputId="bsn" [(ngModel)]="bsn" name="bsn" placeholder="123456789" /> <app-text-input inputId="bsn" [(ngModel)]="bsn" name="bsn" placeholder="123456789" />
</app-form-field> </app-form-field>
<app-form-field label="Wachtwoord" fieldId="pw"> <app-form-field label="Wachtwoord" fieldId="pw" required>
<app-text-input inputId="pw" type="password" [(ngModel)]="password" name="pw" /> <app-text-input inputId="pw" type="password" [(ngModel)]="password" name="pw" />
</app-form-field> </app-form-field>
<div style="margin-top:1rem">
<app-button type="submit" variant="primary">Inloggen met DigiD</app-button> <app-button type="submit" variant="primary">Inloggen met DigiD</app-button>
</div>
</form> </form>
`, `,
}) })

View File

@@ -35,16 +35,16 @@ import { ApiClient } from '@shared/infrastructure/api-client';
(retry)="onRetry()"> (retry)="onRetry()">
@if (step() === 1) { @if (step() === 1) {
<app-form-field label="Gewerkte uren (afgelopen 5 jaar)" fieldId="uren" [error]="errUren()"> <app-form-field label="Gewerkte uren (afgelopen 5 jaar)" fieldId="uren" required [error]="errUren()">
<app-text-input inputId="uren" [ngModel]="draft().uren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'uren', value: $event })" <app-text-input inputId="uren" [ngModel]="draft().uren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'uren', value: $event })"
name="uren" [invalid]="!!errUren()" placeholder="bijv. 4160" /> name="uren" [invalid]="!!errUren()" placeholder="bijv. 4160" />
</app-form-field> </app-form-field>
<app-form-field label="Aantal jaren werkzaam" fieldId="jaren" [error]="errJaren()"> <app-form-field label="Aantal jaren werkzaam" fieldId="jaren" required [error]="errJaren()">
<app-text-input inputId="jaren" [ngModel]="draft().jaren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'jaren', value: $event })" <app-text-input inputId="jaren" [ngModel]="draft().jaren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'jaren', value: $event })"
name="jaren" [invalid]="!!errJaren()" placeholder="bijv. 5" /> name="jaren" [invalid]="!!errJaren()" placeholder="bijv. 5" />
</app-form-field> </app-form-field>
} @else { } @else {
<app-form-field label="Behaalde nascholingspunten" fieldId="punten" [error]="errPunten()"> <app-form-field label="Behaalde nascholingspunten" fieldId="punten" required [error]="errPunten()">
<app-text-input inputId="punten" [ngModel]="draft().punten" (ngModelChange)="dispatch({ tag: 'SetField', key: 'punten', value: $event })" <app-text-input inputId="punten" [ngModel]="draft().punten" (ngModelChange)="dispatch({ tag: 'SetField', key: 'punten', value: $event })"
name="punten" [invalid]="!!errPunten()" placeholder="bijv. 200" /> name="punten" [invalid]="!!errPunten()" placeholder="bijv. 200" />
</app-form-field> </app-form-field>

View File

@@ -52,31 +52,31 @@ const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }];
@switch (step()) { @switch (step()) {
@case ('buitenland') { @case ('buitenland') {
<app-form-field label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?" fieldId="buitenlandGewerkt" [error]="err('buitenlandGewerkt')"> <app-form-field label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?" fieldId="buitenlandGewerkt" required [error]="err('buitenlandGewerkt')">
<app-radio-group name="buitenlandGewerkt" [options]="jaNee" <app-radio-group name="buitenlandGewerkt" [options]="jaNee"
[ngModel]="answers().buitenlandGewerkt ?? ''" (ngModelChange)="set('buitenlandGewerkt', $event)" /> [ngModel]="answers().buitenlandGewerkt ?? ''" (ngModelChange)="set('buitenlandGewerkt', $event)" />
</app-form-field> </app-form-field>
@if (answers().buitenlandGewerkt === 'ja') { @if (answers().buitenlandGewerkt === 'ja') {
<app-form-field label="In welk land?" fieldId="land" [error]="err('land')"> <app-form-field label="In welk land?" fieldId="land" required [error]="err('land')">
<app-text-input inputId="land" [ngModel]="answers().land ?? ''" (ngModelChange)="set('land', $event)" name="land" placeholder="bijv. België" /> <app-text-input inputId="land" [ngModel]="answers().land ?? ''" (ngModelChange)="set('land', $event)" name="land" placeholder="bijv. België" />
</app-form-field> </app-form-field>
<app-form-field label="Hoeveel uur heeft u daar gewerkt?" fieldId="buitenlandseUren" [error]="err('buitenlandseUren')"> <app-form-field label="Hoeveel uur heeft u daar gewerkt?" fieldId="buitenlandseUren" required [error]="err('buitenlandseUren')">
<app-text-input inputId="buitenlandseUren" [ngModel]="answers().buitenlandseUren ?? ''" (ngModelChange)="set('buitenlandseUren', $event)" name="buitenlandseUren" placeholder="bijv. 800" /> <app-text-input inputId="buitenlandseUren" [ngModel]="answers().buitenlandseUren ?? ''" (ngModelChange)="set('buitenlandseUren', $event)" name="buitenlandseUren" placeholder="bijv. 800" />
</app-form-field> </app-form-field>
} }
} }
@case ('werk') { @case ('werk') {
<app-form-field label="Gewerkte uren in Nederland (afgelopen 5 jaar)" fieldId="uren" [error]="err('uren')"> <app-form-field label="Gewerkte uren in Nederland (afgelopen 5 jaar)" fieldId="uren" required [error]="err('uren')">
<app-text-input inputId="uren" [ngModel]="answers().uren ?? ''" (ngModelChange)="set('uren', $event)" name="uren" placeholder="bijv. 4160" /> <app-text-input inputId="uren" [ngModel]="answers().uren ?? ''" (ngModelChange)="set('uren', $event)" name="uren" placeholder="bijv. 4160" />
</app-form-field> </app-form-field>
@if (scholingZichtbaar()) { @if (scholingZichtbaar()) {
<app-form-field label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?" fieldId="scholingGevolgd" [error]="err('scholingGevolgd')"> <app-form-field label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?" fieldId="scholingGevolgd" required [error]="err('scholingGevolgd')">
<app-radio-group name="scholingGevolgd" [options]="jaNee" <app-radio-group name="scholingGevolgd" [options]="jaNee"
[ngModel]="answers().scholingGevolgd ?? ''" (ngModelChange)="set('scholingGevolgd', $event)" /> [ngModel]="answers().scholingGevolgd ?? ''" (ngModelChange)="set('scholingGevolgd', $event)" />
</app-form-field> </app-form-field>
} }
@if (answers().scholingGevolgd === 'ja') { @if (answers().scholingGevolgd === 'ja') {
<app-form-field label="Behaalde nascholingspunten" fieldId="punten" [error]="err('punten')"> <app-form-field label="Behaalde nascholingspunten" fieldId="punten" required [error]="err('punten')">
<app-text-input inputId="punten" [ngModel]="answers().punten ?? ''" (ngModelChange)="set('punten', $event)" name="punten" placeholder="bijv. 200" /> <app-text-input inputId="punten" [ngModel]="answers().punten ?? ''" (ngModelChange)="set('punten', $event)" name="punten" placeholder="bijv. 200" />
</app-form-field> </app-form-field>
} }

View File

@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import { tasksFromProfile } from './tasks';
import { Registration } from './registration';
const base: Registration = {
bigNummer: '12345678901',
naam: 'A. Tester',
beroep: 'arts',
registratiedatum: '2018-01-01',
geboortedatum: '1980-01-01',
status: { tag: 'Geregistreerd', herregistratieDatum: '2026-12-31' },
};
describe('tasksFromProfile', () => {
it('offers herregistratie when within the deadline window', () => {
const tasks = tasksFromProfile(base, new Date('2026-06-27'));
expect(tasks).toHaveLength(1);
expect(tasks[0].to).toBe('/herregistratie');
expect(tasks[0].description).toContain('31 december 2026');
});
it('offers nothing when the deadline is far away', () => {
const far: Registration = { ...base, status: { tag: 'Geregistreerd', herregistratieDatum: '2030-12-31' } };
expect(tasksFromProfile(far, new Date('2026-06-27'))).toHaveLength(0);
});
it('surfaces a notice for a suspended registration', () => {
const reg: Registration = { ...base, status: { tag: 'Geschorst', geschorstTot: '2027-01-01', reden: 'Onderzoek' } };
const tasks = tasksFromProfile(reg, new Date('2026-06-27'));
expect(tasks).toHaveLength(1);
expect(tasks[0].title).toContain('geschorst');
expect(tasks[0].description).toBe('Onderzoek');
});
it('surfaces a notice for a struck-off registration', () => {
const reg: Registration = { ...base, status: { tag: 'Doorgehaald', doorgehaaldOp: '2025-01-01', reden: 'Op eigen verzoek' } };
const tasks = tasksFromProfile(reg, new Date('2026-06-27'));
expect(tasks).toHaveLength(1);
expect(tasks[0].title).toContain('doorgehaald');
});
});

View File

@@ -0,0 +1,59 @@
import { Registration } from './registration';
import { herregistratieDeadline, isHerregistratieEligible } from './registration.policy';
/**
* What the dashboard's "Wat moet ik regelen" list needs. Pure presentation data
* derived from the registration — no Angular. Mirrors the shared TaskItem shape.
*/
export interface PortalTask {
title: string;
description: string;
to: string;
actionLabel: string;
}
function formatNL(d: Date): string {
return d.toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' });
}
/**
* Derive the open tasks for a professional from their registration (pure;
* `today` is injected so it's testable). Herregistratie within its window is the
* primary action; a suspended/struck-off registration surfaces as a notice-task.
* Reuses the same rules the detail/summary use (registration.policy).
*/
export function tasksFromProfile(reg: Registration, today: Date): PortalTask[] {
const tasks: PortalTask[] = [];
if (isHerregistratieEligible(reg, today)) {
const deadline = herregistratieDeadline(reg);
tasks.push({
title: 'Vraag uw herregistratie aan',
description: deadline
? `Verleng uw registratie vóór ${formatNL(deadline)}.`
: 'U kunt nu uw herregistratie aanvragen.',
to: '/herregistratie',
actionLabel: 'Herregistratie aanvragen',
});
}
if (reg.status.tag === 'Geschorst') {
tasks.push({
title: 'Uw registratie is geschorst',
description: reg.status.reden,
to: '/registratie',
actionLabel: 'Bekijk uw gegevens',
});
}
if (reg.status.tag === 'Doorgehaald') {
tasks.push({
title: 'Uw registratie is doorgehaald',
description: reg.status.reden,
to: '/registratie',
actionLabel: 'Bekijk uw gegevens',
});
}
return tasks;
}

View File

@@ -19,20 +19,25 @@ export type AdresErrors = Partial<Record<keyof AdresValue, string>>;
@Component({ @Component({
selector: 'app-address-fields', selector: 'app-address-fields',
imports: [FormsModule, FormFieldComponent, TextInputComponent], imports: [FormsModule, FormFieldComponent, TextInputComponent],
styles: [`
fieldset{border:0;margin:0;padding:0;min-inline-size:0}
legend{padding:0;font-weight:var(--rhc-text-font-weight-semi-bold);margin-block-end:var(--rhc-space-max-md)}
app-form-field + app-form-field{display:block;margin-block-start:var(--rhc-space-max-lg)}
`],
template: ` template: `
<fieldset class="utrecht-form-fieldset utrecht-form-fieldset--html-fieldset"> <fieldset class="utrecht-form-fieldset utrecht-form-fieldset--html-fieldset">
<legend class="utrecht-form-fieldset__legend utrecht-form-fieldset__legend--html-legend">{{ legend() }}</legend> <legend class="utrecht-form-fieldset__legend utrecht-form-fieldset__legend--html-legend">{{ legend() }}</legend>
<app-form-field label="Straat en huisnummer" [fieldId]="idPrefix() + '-straat'" [error]="errors().straat"> <app-form-field label="Straat en huisnummer" [fieldId]="idPrefix() + '-straat'" required [error]="errors().straat">
<app-text-input [inputId]="idPrefix() + '-straat'" [invalid]="!!errors().straat" <app-text-input [inputId]="idPrefix() + '-straat'" [invalid]="!!errors().straat"
[ngModel]="value().straat" (ngModelChange)="fieldChange.emit({ key: 'straat', value: $event })" [ngModel]="value().straat" (ngModelChange)="fieldChange.emit({ key: 'straat', value: $event })"
name="straat" [ngModelOptions]="{ standalone: true }" /> name="straat" [ngModelOptions]="{ standalone: true }" />
</app-form-field> </app-form-field>
<app-form-field label="Postcode" [fieldId]="idPrefix() + '-postcode'" [error]="errors().postcode"> <app-form-field label="Postcode" [fieldId]="idPrefix() + '-postcode'" required [error]="errors().postcode">
<app-text-input [inputId]="idPrefix() + '-postcode'" [invalid]="!!errors().postcode" <app-text-input [inputId]="idPrefix() + '-postcode'" [invalid]="!!errors().postcode"
[ngModel]="value().postcode" (ngModelChange)="fieldChange.emit({ key: 'postcode', value: $event })" [ngModel]="value().postcode" (ngModelChange)="fieldChange.emit({ key: 'postcode', value: $event })"
name="postcode" placeholder="1234 AB" [ngModelOptions]="{ standalone: true }" /> name="postcode" placeholder="1234 AB" [ngModelOptions]="{ standalone: true }" />
</app-form-field> </app-form-field>
<app-form-field label="Woonplaats" [fieldId]="idPrefix() + '-woonplaats'" [error]="errors().woonplaats"> <app-form-field label="Woonplaats" [fieldId]="idPrefix() + '-woonplaats'" required [error]="errors().woonplaats">
<app-text-input [inputId]="idPrefix() + '-woonplaats'" [invalid]="!!errors().woonplaats" <app-text-input [inputId]="idPrefix() + '-woonplaats'" [invalid]="!!errors().woonplaats"
[ngModel]="value().woonplaats" (ngModelChange)="fieldChange.emit({ key: 'woonplaats', value: $event })" [ngModel]="value().woonplaats" (ngModelChange)="fieldChange.emit({ key: 'woonplaats', value: $event })"
name="woonplaats" [ngModelOptions]="{ standalone: true }" /> name="woonplaats" [ngModelOptions]="{ standalone: true }" />

View File

@@ -22,12 +22,12 @@ import { ApiClient } from '@shared/infrastructure/api-client';
<app-alert type="ok"> <app-alert type="ok">
Uw adreswijziging is ontvangen (referentie {{ referentie() }}). U ontvangt binnen 5 werkdagen bericht. Uw adreswijziging is ontvangen (referentie {{ referentie() }}). U ontvangt binnen 5 werkdagen bericht.
</app-alert> </app-alert>
<div style="margin-top:1rem"> <div class="app-section">
<app-button variant="secondary" (click)="dispatch({ tag: 'Reset' })">Nieuwe wijziging doorgeven</app-button> <app-button variant="secondary" (click)="dispatch({ tag: 'Reset' })">Nieuwe wijziging doorgeven</app-button>
</div> </div>
} @else { } @else {
<app-heading [level]="2">Adreswijziging doorgeven</app-heading> <app-heading [level]="2">Adreswijziging doorgeven</app-heading>
<form (ngSubmit)="onSubmit()" style="max-width:28rem"> <form (ngSubmit)="onSubmit()" class="app-form app-form-panel app-section">
<app-address-fields <app-address-fields
idPrefix="cr" idPrefix="cr"
[value]="adres()" [value]="adres()"
@@ -35,14 +35,12 @@ import { ApiClient } from '@shared/infrastructure/api-client';
(fieldChange)="dispatch({ tag: 'SetField', key: $event.key, value: $event.value })" /> (fieldChange)="dispatch({ tag: 'SetField', key: $event.key, value: $event.value })" />
@if (failedError()) { @if (failedError()) {
<div style="margin-top:1rem"><app-alert type="error">Het indienen is niet gelukt: {{ failedError() }}</app-alert></div> <app-alert type="error">Het indienen is niet gelukt: {{ failedError() }}</app-alert>
} }
<div style="margin-top:1rem">
<app-button type="submit" variant="primary" [disabled]="state().tag === 'Submitting'"> <app-button type="submit" variant="primary" [disabled]="state().tag === 'Submitting'">
{{ state().tag === 'Submitting' ? 'Bezig met indienen…' : 'Wijziging indienen' }} {{ state().tag === 'Submitting' ? 'Bezig met indienen…' : 'Wijziging indienen' }}
</app-button> </app-button>
</div>
</form> </form>
} }
`, `,

View File

@@ -5,36 +5,61 @@ import { LinkComponent } from '@shared/ui/link/link.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/alert/alert.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { CardComponent } from '@shared/ui/card/card.component';
import { TaskListComponent } from '@shared/ui/task-list/task-list.component';
import { SideNavComponent, NavItem } from '@shared/layout/side-nav/side-nav.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/async/async.component';
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component'; import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
import { RegistrationTableComponent } from '@registratie/ui/registration-table/registration-table.component'; import { RegistrationTableComponent } from '@registratie/ui/registration-table/registration-table.component';
import { BigProfileStore } from '@registratie/application/big-profile.store'; import { BigProfileStore } from '@registratie/application/big-profile.store';
import { Registration } from '@registratie/domain/registration';
import { tasksFromProfile } from '@registratie/domain/tasks';
/** Page: "Mijn overzicht" — the portal home, following the NL Design System
"Mijn omgeving" pattern (side nav + "Wat moet ik regelen" + "Mijn zaken"). */
@Component({ @Component({
selector: 'app-dashboard-page', selector: 'app-dashboard-page',
imports: [ imports: [
PageShellComponent, HeadingComponent, LinkComponent, AlertComponent, SkeletonComponent, PageShellComponent, HeadingComponent, LinkComponent, AlertComponent, SkeletonComponent,
DataRowComponent, ...ASYNC, RegistrationSummaryComponent, RegistrationTableComponent, DataRowComponent, CardComponent, TaskListComponent, SideNavComponent, ...ASYNC,
RegistrationSummaryComponent, RegistrationTableComponent,
], ],
template: ` template: `
<app-page-shell heading="Mijn BIG-registratie"> <app-page-shell heading="Mijn overzicht" intro="Welkom in uw persoonlijke omgeving van het BIG-register. Hier ziet u uw registratie en regelt u uw zaken.">
<div class="app-overview">
<app-side-nav [items]="nav" />
<div class="app-stack"> <div class="app-stack">
@if (store.pendingHerregistratie()) { @if (store.pendingHerregistratie()) {
<app-alert type="info">Uw herregistratie-aanvraag is in behandeling.</app-alert> <app-alert type="info">Uw herregistratie-aanvraag is in behandeling.</app-alert>
} }
<!-- ONE state for two combined services (BIG-register + BRP). -->
<app-async [data]="store.profile()"> <app-async [data]="store.profile()">
<ng-template appAsyncLoaded let-p> <ng-template appAsyncLoaded let-p>
@let tasks = tasksFor($any(p).registration);
<section>
<app-heading [level]="2">Wat moet ik regelen</app-heading>
@if (tasks.length) {
<app-task-list class="app-section" [tasks]="tasks" />
} @else {
<p class="rhc-paragraph app-text-subtle">U heeft op dit moment niets openstaan.</p>
}
</section>
<section>
<app-heading [level]="2">Mijn registratie</app-heading>
<div class="app-section">
<app-registration-summary [reg]="$any(p).registration" /> <app-registration-summary [reg]="$any(p).registration" />
<div class="rhc-card rhc-card--default app-section"> </div>
<app-heading [level]="2">Persoonsgegevens (BRP)</app-heading> <app-card class="app-section" heading="Persoonsgegevens (BRP)">
<dl class="rhc-data-summary rhc-data-summary--row"> <dl class="rhc-data-summary rhc-data-summary--row">
<app-data-row key="Straat" [value]="$any(p).person.adres.straat" /> <app-data-row key="Straat" [value]="$any(p).person.adres.straat" />
<app-data-row key="Postcode" [value]="$any(p).person.adres.postcode" /> <app-data-row key="Postcode" [value]="$any(p).person.adres.postcode" />
<app-data-row key="Woonplaats" [value]="$any(p).person.adres.woonplaats" /> <app-data-row key="Woonplaats" [value]="$any(p).person.adres.woonplaats" />
</dl> </dl>
</div> </app-card>
</section>
</ng-template> </ng-template>
<ng-template appAsyncLoading> <ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="6" /> <app-skeleton height="2.5rem" [count]="6" />
@@ -43,6 +68,7 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
<section> <section>
<app-heading [level]="2">Specialismen en aantekeningen</app-heading> <app-heading [level]="2">Specialismen en aantekeningen</app-heading>
<div class="app-section">
<app-async [data]="store.aantekeningen()"> <app-async [data]="store.aantekeningen()">
<ng-template appAsyncLoaded let-r> <ng-template appAsyncLoaded let-r>
<app-registration-table [rows]="$any(r)" /> <app-registration-table [rows]="$any(r)" />
@@ -51,36 +77,50 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
<app-skeleton height="2.5rem" [count]="3" /> <app-skeleton height="2.5rem" [count]="3" />
</ng-template> </ng-template>
<ng-template appAsyncEmpty> <ng-template appAsyncEmpty>
<p class="rhc-paragraph">U heeft nog geen specialismen of aantekeningen.</p> <p class="rhc-paragraph app-text-subtle">U heeft nog geen specialismen of aantekeningen.</p>
</ng-template> </ng-template>
</app-async> </app-async>
</div>
</section> </section>
<section> <section>
<app-heading [level]="2">Wat wilt u doen?</app-heading> <app-heading [level]="2">Wat wilt u doen?</app-heading>
<ul class="app-card-grid app-section"> <ul class="app-card-grid app-section">
@for (a of acties; track a.to) { @for (a of acties; track a.to) {
<li class="rhc-card rhc-card--default"> <li>
<app-heading [level]="3">{{ a.titel }}</app-heading> <app-card [heading]="a.titel">
<p class="rhc-paragraph">{{ a.tekst }}</p> <p class="rhc-paragraph">{{ a.tekst }}</p>
<app-link [to]="a.to">{{ a.actie }} →</app-link> <app-link [to]="a.to">{{ a.actie }} →</app-link>
</app-card>
</li> </li>
} }
</ul> </ul>
</section> </section>
</div> </div>
</div>
</app-page-shell> </app-page-shell>
`, `,
}) })
export class DashboardPage { export class DashboardPage {
protected store = inject(BigProfileStore); protected store = inject(BigProfileStore);
private readonly today = new Date();
/** Primary actions, rendered as an accessible card grid. */ protected tasksFor(reg: Registration) {
return tasksFromProfile(reg, this.today);
}
/** Portal sections (navigation, not actions). */
protected readonly nav: NavItem[] = [
{ label: 'Overzicht', to: '/dashboard' },
{ label: 'Mijn gegevens', to: '/registratie' },
{ label: 'Herregistratie', to: '/herregistratie' },
{ label: 'Inschrijven', to: '/registreren' },
];
/** Primary transactional actions, as a card grid. */
protected readonly acties = [ protected readonly acties = [
{ to: '/registreren', titel: 'Inschrijven', tekst: 'Schrijf u in in het BIG-register via de registratiewizard.', actie: 'Start inschrijving' }, { to: '/registreren', titel: 'Inschrijven', tekst: 'Schrijf u in in het BIG-register via de registratiewizard.', actie: 'Start inschrijving' },
{ to: '/registratie', titel: 'Mijn gegevens', tekst: 'Bekijk uw gegevens of geef een wijziging door.', actie: 'Bekijk gegevens' }, { to: '/herregistratie', titel: 'Herregistratie aanvragen', tekst: 'Verleng uw registratie voor de komende periode.', actie: 'Vraag aan' },
{ to: '/herregistratie', titel: 'Herregistratie', tekst: 'Vraag uw herregistratie aan.', actie: 'Vraag aan' }, { to: '/registratie', titel: 'Gegevens wijzigen', tekst: 'Bekijk uw gegevens of geef een wijziging door.', actie: 'Bekijk gegevens' },
{ to: '/intake', titel: 'Herregistratie-intake', tekst: 'Vragenlijst met vertakkingen.', actie: 'Start intake' },
{ to: '/concepts', titel: 'Functionele patronen', tekst: 'Onmogelijke toestanden onmogelijk maken.', actie: 'Bekijk patronen' },
]; ];
} }

View File

@@ -83,12 +83,12 @@ const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed"
[value]="{ straat: draft().straat ?? '', postcode: draft().postcode ?? '', woonplaats: draft().woonplaats ?? '' }" [value]="{ straat: draft().straat ?? '', postcode: draft().postcode ?? '', woonplaats: draft().woonplaats ?? '' }"
[errors]="{ straat: err('straat'), postcode: err('postcode'), woonplaats: err('woonplaats') }" [errors]="{ straat: err('straat'), postcode: err('postcode'), woonplaats: err('woonplaats') }"
(fieldChange)="set($event.key, $event.value)" /> (fieldChange)="set($event.key, $event.value)" />
<app-form-field label="Hoe wilt u correspondentie ontvangen?" fieldId="correspondentie" [error]="err('correspondentie')"> <app-form-field label="Hoe wilt u correspondentie ontvangen?" fieldId="correspondentie" required [error]="err('correspondentie')">
<app-radio-group name="correspondentie" [options]="kanalen" [invalid]="!!err('correspondentie')" <app-radio-group name="correspondentie" [options]="kanalen" [invalid]="!!err('correspondentie')"
[ngModel]="draft().correspondentie ?? ''" (ngModelChange)="setKanaal($event)" /> [ngModel]="draft().correspondentie ?? ''" (ngModelChange)="setKanaal($event)" />
</app-form-field> </app-form-field>
@if (draft().correspondentie === 'email') { @if (draft().correspondentie === 'email') {
<app-form-field label="E-mailadres" fieldId="email" [error]="err('email')"> <app-form-field label="E-mailadres" fieldId="email" required [error]="err('email')">
<app-text-input inputId="email" type="email" [invalid]="!!err('email')" [ngModel]="draft().email ?? ''" (ngModelChange)="set('email', $event)" name="email" placeholder="naam@voorbeeld.nl" /> <app-text-input inputId="email" type="email" [invalid]="!!err('email')" [ngModel]="draft().email ?? ''" (ngModelChange)="set('email', $event)" name="email" placeholder="naam@voorbeeld.nl" />
</app-form-field> </app-form-field>
} }
@@ -97,7 +97,7 @@ const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed"
@case ('beroep') { @case ('beroep') {
<app-async [data]="lookupRd()"> <app-async [data]="lookupRd()">
<ng-template appAsyncLoaded let-data> <ng-template appAsyncLoaded let-data>
<app-form-field label="Kies het diploma waarmee u zich wilt registreren" fieldId="diploma" [error]="err('diploma')"> <app-form-field label="Kies het diploma waarmee u zich wilt registreren" fieldId="diploma" required [error]="err('diploma')">
<app-radio-group name="diploma" [options]="diplomaOptions($any(data))" [invalid]="!!err('diploma')" <app-radio-group name="diploma" [options]="diplomaOptions($any(data))" [invalid]="!!err('diploma')"
[ngModel]="diplomaKeuze()" (ngModelChange)="onDiplomaKeuze($any(data), $event)" /> [ngModel]="diplomaKeuze()" (ngModelChange)="onDiplomaKeuze($any(data), $event)" />
</app-form-field> </app-form-field>

View File

@@ -11,8 +11,7 @@ import { RegistratieWizardComponent } from '@registratie/ui/registratie-wizard/r
template: ` template: `
<app-page-shell <app-page-shell
heading="Inschrijven in het BIG-register" heading="Inschrijven in het BIG-register"
backLink="/dashboard" backLink="/dashboard">
[breadcrumb]="[{ label: 'Mijn omgeving', link: '/dashboard' }, { label: 'Inschrijven in het BIG-register' }]">
<app-alert type="info"> <app-alert type="info">
In drie stappen schrijft u zich in: uw adres en correspondentievoorkeur, het In drie stappen schrijft u zich in: uw adres en correspondentievoorkeur, het
diploma waarmee u zich registreert, en een controle. Uw gegevens blijven bewaard diploma waarmee u zich registreert, en een controle. Uw gegevens blijven bewaard

View File

@@ -23,7 +23,7 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
</ng-template> </ng-template>
</app-async> </app-async>
<div style="margin-top:2rem"> <div class="app-section">
<app-change-request-form /> <app-change-request-form />
</div> </div>
</app-page-shell> </app-page-shell>

View File

@@ -4,13 +4,14 @@ import { Registration } from '@registratie/domain/registration';
import { statusColor, statusLabel } from '@registratie/domain/registration.policy'; import { statusColor, statusLabel } from '@registratie/domain/registration.policy';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component'; import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component';
import { CardComponent } from '@shared/ui/card/card.component';
/** Organism: registration summary card. Composes data-row molecules + status-badge atom. */ /** Organism: registration summary card. Composes data-row molecules + status-badge atom. */
@Component({ @Component({
selector: 'app-registration-summary', selector: 'app-registration-summary',
imports: [DatePipe, DataRowComponent, StatusBadgeComponent], imports: [DatePipe, DataRowComponent, StatusBadgeComponent, CardComponent],
template: ` template: `
<div class="rhc-card rhc-card--default"> <app-card>
<dl class="rhc-data-summary rhc-data-summary--row"> <dl class="rhc-data-summary rhc-data-summary--row">
<app-data-row key="BIG-nummer" [value]="reg().bigNummer" /> <app-data-row key="BIG-nummer" [value]="reg().bigNummer" />
<app-data-row key="Naam" [value]="reg().naam" /> <app-data-row key="Naam" [value]="reg().naam" />
@@ -34,7 +35,7 @@ import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.compo
} }
} }
</dl> </dl>
</div> </app-card>
`, `,
}) })
export class RegistrationSummaryComponent { export class RegistrationSummaryComponent {

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], imports: [RouterLink],
styles: [` styles: [`
:host{display:block} :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)} .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: ` template: `
<nav class="rhc-breadcrumb-nav utrecht-breadcrumb-nav" aria-label="Kruimelpad"> <nav class="rhc-breadcrumb-nav utrecht-breadcrumb-nav" aria-label="Kruimelpad">
<ol class="utrecht-breadcrumb-nav__list list"> <ol class="utrecht-breadcrumb-nav__list list">
@for (item of items(); track item.label; let last = $last) { @for (item of items(); track item.label; let last = $last; let first = $first) {
<li class="utrecht-breadcrumb-nav__item"> <li class="utrecht-breadcrumb-nav__item crumb">
@if (!first) { <span class="sep" aria-hidden="true"></span> }
@if (item.link && !last) { @if (item.link && !last) {
<a class="utrecht-breadcrumb-nav__link rhc-link nl-link" [routerLink]="item.link">{{ item.label }}</a> <a [routerLink]="item.link">{{ item.label }}</a>
<span class="sep" aria-hidden="true">/</span>
} @else { } @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> </li>
} }

View File

@@ -1,27 +1,24 @@
import { Component, input } from '@angular/core'; import { Component, input } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { LinkComponent } from '@shared/ui/link/link.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 /** Template: standard page body — optional back-link, a heading, optional intro,
heading, optional intro, and projected content. Rendered inside the persistent and projected content. The breadcrumb lives in the site header (blue bar), so
ShellComponent via the router outlet, so it owns only the content (not chrome). */ it's not repeated here. Rendered inside the persistent ShellComponent via the
router outlet, so it owns only the content (not chrome). */
@Component({ @Component({
selector: 'app-page-shell', selector: 'app-page-shell',
imports: [HeadingComponent, LinkComponent, BreadcrumbComponent], imports: [HeadingComponent, LinkComponent],
styles: [` styles: [`
:host{display:block} :host{display:block}
.body--narrow{max-inline-size:var(--app-form-narrow)} .body--narrow{max-inline-size:var(--app-form-narrow)}
.crumb{margin-block-end:var(--rhc-space-max-xl)} .back{margin:0 0 var(--rhc-space-max-lg)}
.intro{margin-block-end:var(--rhc-space-max-2xl)} .intro{margin-block:var(--rhc-space-max-md) var(--rhc-space-max-2xl);max-inline-size:42rem;color:var(--rhc-color-foreground-subtle)}
`], `],
template: ` template: `
<div [class.body--narrow]="width() === 'narrow'"> <div [class.body--narrow]="width() === 'narrow'">
@if (breadcrumb()) {
<app-breadcrumb class="crumb" [items]="breadcrumb()!" />
}
@if (backLink()) { @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> <app-heading [level]="1">{{ heading() }}</app-heading>
@if (intro()) { @if (intro()) {
@@ -37,5 +34,4 @@ export class PageShellComponent {
backLink = input<string>(); backLink = input<string>();
backLabel = input('Terug naar overzicht'); backLabel = input('Terug naar overzicht');
width = input<'default' | 'narrow'>('default'); 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'; 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({ @Component({
selector: 'app-site-footer', selector: 'app-site-footer',
styles: [` styles: [`
:host{display:block} :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%} .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-2xl);display:flex;gap:var(--rhc-space-max-3xl);flex-wrap:wrap;box-sizing:border-box} .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}
.end{margin-inline-start:auto} .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: ` template: `
<footer class="utrecht-page-footer bar"> <footer class="utrecht-page-footer bar">
<div class="inner"> <div class="inner">
<span>BIG-register</span> <div>
<span>CIBG — Ministerie van Volksgezondheid, Welzijn en Sport</span> <div class="tagline">De Rijksoverheid. Voor Nederland.</div>
<span class="end">Demo / POC — geen echte gegevens</span> <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> </div>
</footer> </footer>
`, `,

View File

@@ -1,37 +1,75 @@
import { Component, input } from '@angular/core'; import { Component, computed, inject, input } from '@angular/core';
import { RouterLink } from '@angular/router'; 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. /** Organism: Rijksoverheid-style site header. Two tiers, like rijksoverheid.nl:
ponytail: text wordmark instead of the licensed Rijksoverheid logo. */ 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({ @Component({
selector: 'app-site-header', selector: 'app-site-header',
imports: [RouterLink], imports: [RouterLink, BreadcrumbComponent],
// :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).
styles: [` styles: [`
:host{display:block} :host{display:block}
.bar{background:var(--rhc-color-lintblauw-700);color:var(--rhc-color-wit);inline-size:100%} /* Tier 1 — white brand bar */
.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} .brandbar{background:var(--rhc-color-wit);border-block-end:var(--rhc-border-width-sm) solid var(--rhc-color-border-subtle);inline-size:100%}
.brand{display:flex;align-items:center;gap:var(--rhc-space-max-lg);color:inherit;text-decoration:none} .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}
.mark{display:inline-block;inline-size:var(--rhc-space-max-md);block-size:var(--rhc-space-max-4xl);background:var(--rhc-color-wit)} .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)} .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)} .sub{font-weight:var(--rhc-text-font-weight-regular);font-size:var(--rhc-text-font-size-sm);color:var(--rhc-color-foreground-subtle)}
.portal{margin-inline-start:auto;font-weight:var(--rhc-text-font-weight-semi-bold)} .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: ` template: `
<header class="utrecht-page-header bar"> <div class="brandbar">
<div class="inner"> <div class="inner">
<a routerLink="/dashboard" class="brand"> <a routerLink="/dashboard" class="brand">
<span aria-hidden="true" class="mark"></span> <span aria-hidden="true" class="mark"></span>
<span class="name"> <span class="name">Rijksoverheid<br><span class="sub">BIG-register</span></span>
Rijksoverheid<br><span class="sub">BIG-register</span>
</span>
</a> </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> </div>
</header> </div>
@if (trail().length) {
<div class="navbar">
<div class="inner">
<app-breadcrumb class="inverse" [items]="trail()" />
</div>
</div>
}
`, `,
}) })
export class SiteHeaderComponent { export class SiteHeaderComponent {
subtitle = input('Mijn omgeving'); 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 { Component, ElementRef, effect, input, output, untracked, viewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/alert/alert.component';
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component'; import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
@@ -25,7 +26,7 @@ export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed';
*/ */
@Component({ @Component({
selector: 'app-wizard-shell', selector: 'app-wizard-shell',
imports: [ButtonComponent, AlertComponent, SpinnerComponent, StepperComponent], imports: [FormsModule, ButtonComponent, AlertComponent, SpinnerComponent, StepperComponent],
styles: [` styles: [`
.es-title{margin:0 0 var(--rhc-space-max-sm)} .es-title{margin:0 0 var(--rhc-space-max-sm)}
.es-list{margin:0;padding-inline-start:var(--rhc-space-max-xl)} .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> <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"> <ul class="es-list">
@for (e of errors(); track e.id) { @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> </ul>
</app-alert> </app-alert>
</div> </div>
} }
<form (ngSubmit)="primary.emit()" class="app-form"> <form (ngSubmit)="primary.emit()" class="app-form app-form-panel">
<ng-content /> <ng-content />
<div class="app-button-row app-button-row--spaced"> <div class="app-button-row app-button-row--spaced">
@if (canGoBack()) { @if (canGoBack()) {
@@ -89,6 +90,14 @@ export class WizardShellComponent {
cancel = output<void>(); cancel = output<void>();
retry = 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 stepHeading = viewChild<ElementRef<HTMLElement>>('stepHeading');
private errorSummary = viewChild<ElementRef<HTMLElement>>('errorSummary'); private errorSummary = viewChild<ElementRef<HTMLElement>>('errorSummary');
@@ -102,14 +111,18 @@ export class WizardShellComponent {
if (firstStep) { firstStep = false; return; } if (firstStep) { firstStep = false; return; }
untracked(() => queueMicrotask(() => this.stepHeading()?.nativeElement.focus())); untracked(() => queueMicrotask(() => this.stepHeading()?.nativeElement.focus()));
}); });
// A11y: when validation errors appear (after a failed submit), move focus to // A11y: when validation errors first appear (after a failed submit), move
// the error summary so it's announced. The reducer returns a fresh errors // focus to the error summary so it's announced. Only on the rising edge
// object per attempt, so resubmitting the same invalid step re-announces. // (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 firstErr = true;
let hadErrors = false;
effect(() => { effect(() => {
const has = this.errors().length > 0; const has = this.errors().length > 0;
if (firstErr) { firstErr = false; return; } if (firstErr) { firstErr = false; hadErrors = has; return; }
if (has) untracked(() => queueMicrotask(() => this.errorSummary()?.nativeElement.focus())); 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. /** Molecule: form field = label + projected control + optional error/description.
Reused by both the login form and the change-request form. */ Reused by both the login form and the change-request form. */
@Component({ @Component({
selector: 'app-form-field', 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: ` template: `
<div class="rhc-form-field utrecht-form-field" [class.utrecht-form-field--invalid]="!!error()"> <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()) { @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 /> <ng-content />
@if (error()) { @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> </div>
`, `,
@@ -22,4 +31,5 @@ export class FormFieldComponent {
fieldId = input.required<string>(); fieldId = input.required<string>();
description = input<string>(); description = input<string>();
error = input<string>(); error = input<string>();
required = input(false, { transform: booleanAttribute });
} }

View File

@@ -8,18 +8,33 @@ import { Component, input } from '@angular/core';
styles: [` styles: [`
:host{display:block} :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} .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} .steps{display:flex;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)} .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{ .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); inline-size:var(--rhc-space-max-4xl);block-size:var(--rhc-space-max-4xl);
border-radius:var(--rhc-border-radius-round); 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); 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{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)} .step--current .label{font-weight:var(--rhc-text-font-weight-semi-bold)}
`], `],
template: ` 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). */ /** Atom: text input. Utrecht textbox wired up as a form control (ngModel/reactive). */
@Component({ @Component({
selector: 'app-text-input', selector: 'app-text-input',
styles: [`
:host{display:block}
input{inline-size:100%;box-sizing:border-box}
`],
template: ` template: `
<input <input
class="utrecht-textbox rhc-text-input" class="utrecht-textbox rhc-text-input"

View File

@@ -6,15 +6,18 @@
@import '@rijkshuisstijl-community/components-css/dist/index.css'; /* component classes */ @import '@rijkshuisstijl-community/components-css/dist/index.css'; /* component classes */
html, body { margin: 0; min-height: 100%; } html, body { margin: 0; min-height: 100%; }
/* Rijkshuisstijl pages sit on white; cards/panels carry the subtle grey. */
body { background: var(--rhc-color-wit); color: var(--rhc-color-foreground-default); }
/* App theme layer: a few app-specific measures RHC has no token for (content/form /* App theme layer: a few app-specific measures RHC has no token for (content/form
widths), defined ONCE here and mapped onto RHC where possible. Components reference widths), defined ONCE here and mapped onto RHC where possible. Components reference
these tokens instead of raw values. */ these tokens instead of raw values. */
:root { :root {
--app-content-max: 64rem; /* readable page column */ --app-content-max: 67rem; /* readable page column */
--app-form-max: 30rem; /* default wizard form width */ --app-form-max: 32rem; /* default wizard form width */
--app-form-narrow: 32rem; /* page-shell narrow variant */ --app-form-narrow: 32rem; /* page-shell narrow variant */
--app-skip-link-offset: -999px; /* off-screen skip link */ --app-skip-link-offset: -999px; /* off-screen skip link */
--app-sidebar-width: 15rem; /* overview side navigation */
} }
/* App utility classes: centralise the repeated inline layout idioms so components stay /* App utility classes: centralise the repeated inline layout idioms so components stay
@@ -41,6 +44,36 @@ html, body { margin: 0; min-height: 100%; }
padding: 0; padding: 0;
} }
/* Overview ("Mijn omgeving") layout: side navigation + main column. Collapses to
a single column on narrow viewports (nav stacks above the content). */
.app-overview {
display: grid;
grid-template-columns: var(--app-sidebar-width) minmax(0, 1fr);
gap: var(--rhc-space-max-3xl);
align-items: start;
}
@media (max-width: 48rem) {
.app-overview { grid-template-columns: 1fr; }
}
/* Rijkshuisstijl form panel: the subtle-grey block a form sits in (see the
rijksoverheid.nl contact-form reference). */
.app-form-panel {
background: var(--rhc-color-cool-grey-100);
border-radius: var(--rhc-border-radius-md);
padding: var(--rhc-space-max-2xl);
}
/* Vertical rhythm between fields projected into a form panel. Global (not
component-scoped) so it reaches projected content past view encapsulation. */
.app-form-panel > * + * { margin-block-start: var(--rhc-space-max-xl); }
/* Bordered info box (outline, no fill) — e.g. a "Vragen?" contact block. */
.app-info-box {
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
border-radius: var(--rhc-border-radius-md);
padding: var(--rhc-space-max-xl);
}
/* Route transitions (withViewTransitions): cross-fade the routed CONTENT only. /* Route transitions (withViewTransitions): cross-fade the routed CONTENT only.
The chrome gets its own stable view-transition-name so it's lifted out of the The chrome gets its own stable view-transition-name so it's lifted out of the
`root` snapshot and stays put while the content fades. */ `root` snapshot and stays put while the content fades. */