Restructure into DDD bounded contexts + functional state management

Reorganise from atomic-design-only folders into bounded contexts
(auth / registratie / herregistratie) over a shared kernel, each split into
domain / application / infrastructure / ui layers. Dependencies point inward;
the domain layer is framework-free. Path aliases (@shared/@auth/@registratie/
@herregistratie) make import direction explicit.

State management (Elm-style, native TS, no new deps):
- shared/application/store.ts — createStore(init, update): pure reducer + signal
- shared/application/remote-data.ts — add map/map2/map3/andThen combinators so
  several services fold into one RemoteData; <app-async> gains an [rd] input
- registratie/application/big-profile.store.ts — root singleton combining the
  BIG-register and BRP services via map2 into one state; holds the optimistic
  herregistratie flag shared with the dashboard
- herregistratie: machine gains a WizardMsg union + pure reduce; submit is a
  command that calls infra and dispatches the result, with optimistic update +
  rollback against the shared store
- auth: SessionStore + DigiD adapter + functional route guard; login establishes
  the session, protected routes use canActivate

Rich domain: registration.policy.ts (statusColor/label, herregistratie
eligibility, invariants); BigNummer/Postcode/Uren value objects with smart
constructors. status-badge is now domain-free (colour/label inputs).

Specs for the reducer, RemoteData combinators, and eligibility policy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 07:20:13 +02:00
parent 6bd6e854c7
commit 2114514ad7
74 changed files with 841 additions and 347 deletions

View File

@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import { RemoteData, map2, map } from './remote-data';
const loading: RemoteData<string, number> = { tag: 'Loading' };
const failure: RemoteData<string, number> = { tag: 'Failure', error: 'x' };
const ok = (n: number): RemoteData<string, number> => ({ tag: 'Success', value: n });
describe('RemoteData combinators', () => {
it('map only touches Success', () => {
const times10 = (n: number) => n * 10;
expect(map(ok(2), times10)).toEqual({ tag: 'Success', value: 20 });
expect(map(loading, times10)).toEqual(loading);
});
it('map2 precedence: Failure > Loading > Success', () => {
const add = (a: number, b: number) => a + b;
expect(map2(failure, ok(1), add)).toEqual(failure); // a failed
expect(map2(ok(1), failure, add)).toEqual(failure); // b failed
expect(map2(loading, ok(1), add)).toEqual({ tag: 'Loading' });
expect(map2(ok(2), ok(3), add)).toEqual({ tag: 'Success', value: 5 });
});
});

View File

@@ -0,0 +1,90 @@
import type { Resource } from '@angular/core';
import { assertNever } from '@shared/kernel/fp';
/**
* The four mutually-exclusive states of an async fetch, as a tagged union.
* Crucially the data lives ON the state: only `Failure` has an `error`, only
* `Success` has a `value`. "Loaded but no value" or "error with stale value"
* are unrepresentable — Richard Feldman's RemoteData.
*/
export type RemoteData<E, T> =
| { tag: 'Loading' }
| { tag: 'Empty' }
| { tag: 'Failure'; error: E }
| { tag: 'Success'; value: T };
/** Project Angular's loosely-typed Resource into a RemoteData value. */
export function fromResource<T>(
r: Resource<T>,
isEmpty: (v: T) => boolean = () => false,
): RemoteData<Error | undefined, T> {
if (r.status() === 'error') return { tag: 'Failure', error: r.error() };
if (r.status() === 'loading') return { tag: 'Loading' };
if (r.hasValue()) {
const v = r.value();
return isEmpty(v) ? { tag: 'Empty' } : { tag: 'Success', value: v };
}
return { tag: 'Loading' };
}
/** Exhaustive fold: you must handle every case, checked at compile time. */
export function foldRemote<E, T, R>(
rd: RemoteData<E, T>,
h: { loading: () => R; empty: () => R; failure: (e: E) => R; success: (v: T) => R },
): R {
switch (rd.tag) {
case 'Loading':
return h.loading();
case 'Empty':
return h.empty();
case 'Failure':
return h.failure(rd.error);
case 'Success':
return h.success(rd.value);
default:
return assertNever(rd);
}
}
// --- Combinators -----------------------------------------------------------
// Let several independent async sources be treated as one. When you combine
// two streams the result is: a failure if EITHER failed, still loading if
// either is loading, empty if either is empty, and only Success when BOTH
// succeeded. Precedence: Failure > Loading > Empty > Success.
/** Transform the value inside a Success; pass other states through unchanged. */
export function map<E, A, B>(rd: RemoteData<E, A>, f: (a: A) => B): RemoteData<E, B> {
return rd.tag === 'Success' ? { tag: 'Success', value: f(rd.value) } : rd;
}
/** Combine two sources into one. Use this to merge e.g. a BIG-register call
and a BRP call into a single state the page can render. */
export function map2<E, A, B, R>(
a: RemoteData<E, A>,
b: RemoteData<E, B>,
f: (a: A, b: B) => R,
): RemoteData<E, R> {
if (a.tag === 'Failure') return a;
if (b.tag === 'Failure') return b;
if (a.tag === 'Loading' || b.tag === 'Loading') return { tag: 'Loading' };
if (a.tag === 'Empty' || b.tag === 'Empty') return { tag: 'Empty' };
return { tag: 'Success', value: f(a.value, b.value) };
}
/** Combine three sources (built on map2). */
export function map3<E, A, B, C, R>(
a: RemoteData<E, A>,
b: RemoteData<E, B>,
c: RemoteData<E, C>,
f: (a: A, b: B, c: C) => R,
): RemoteData<E, R> {
return map2(map2(a, b, (x, y) => [x, y] as const), c, ([x, y], z) => f(x, y, z));
}
/** Chain a second source that depends on the first one's value. */
export function andThen<E, A, B>(
rd: RemoteData<E, A>,
f: (a: A) => RemoteData<E, B>,
): RemoteData<E, B> {
return rd.tag === 'Success' ? f(rd.value) : rd;
}

View File

@@ -0,0 +1,29 @@
import { Signal, signal } from '@angular/core';
/**
* A tiny "Elm-style" store. The whole idea: all state lives in ONE value
* (the Model). The only way to change it is to send a message (Msg) to a PURE
* function `update(model, msg)` that returns the next Model. Nothing else
* mutates state, so to understand the app you only read the update function.
*
* Side effects (HTTP, timers) do NOT go in `update` — that stays pure and easy
* to test. Instead, effectful "command" functions call the network and then
* `dispatch` a message describing what happened (e.g. Loaded / Failed).
*/
export interface Store<Model, Msg> {
/** The current state, as a read-only Angular signal. */
readonly model: Signal<Model>;
/** Send a message; the model becomes update(model, msg). */
dispatch(msg: Msg): void;
}
export function createStore<Model, Msg>(
init: Model,
update: (model: Model, msg: Msg) => Model,
): Store<Model, Msg> {
const model = signal(init);
return {
model: model.asReadonly(),
dispatch: (msg) => model.set(update(model(), msg)),
};
}

View File

@@ -0,0 +1,29 @@
import { HttpErrorResponse, HttpInterceptorFn, HttpResponse } from '@angular/common/http';
import { of, switchMap, throwError, timer } from 'rxjs';
import { delay } from 'rxjs/operators';
import { currentScenario } from './scenario';
/**
* Demo-only: rewrites the timing/outcome of mock data requests based on
* ?scenario= so loading / empty / error states can be shown on demand.
* Real requests are untouched.
*/
export const scenarioInterceptor: HttpInterceptorFn = (req, next) => {
if (!req.url.includes('mock/')) return next(req);
switch (currentScenario()) {
case 'slow':
return next(req).pipe(delay(2500));
case 'loading':
return next(req).pipe(delay(600_000)); // effectively never resolves
case 'empty':
return of(new HttpResponse({ status: 200, body: [] })).pipe(delay(400));
case 'error':
return timer(400).pipe(
switchMap(() => throwError(() =>
new HttpErrorResponse({ status: 500, statusText: 'Demo-fout', url: req.url }))),
);
default:
return next(req);
}
};

View File

@@ -0,0 +1,9 @@
export type Scenario = 'default' | 'slow' | 'loading' | 'empty' | 'error';
const VALID: Scenario[] = ['default', 'slow', 'loading', 'empty', 'error'];
/** Reads ?scenario= from the URL so a demo can force each async state. */
export function currentScenario(): Scenario {
const s = new URLSearchParams(window.location.search).get('scenario') as Scenario | null;
return s && VALID.includes(s) ? s : 'default';
}

View File

@@ -0,0 +1,23 @@
/**
* Tiny native-TS functional toolkit. No dependency — this is the whole "library".
* Reused by every "impossible states" concept in the POC.
*/
/** Exhaustiveness guard: put in the `default` arm of a union switch. Adding a
new variant without handling it then fails to compile (x is no longer never). */
export function assertNever(x: never): never {
throw new Error('Unexpected variant: ' + JSON.stringify(x));
}
/** A computation that either succeeded with a value or failed with an error.
Plain objects (no classes) to match the signal/httpResource ergonomics. */
export type Result<E, T> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: E };
export const ok = <T>(value: T): Result<never, T> => ({ ok: true, value });
export const err = <E>(error: E): Result<E, never> => ({ ok: false, error });
/** Nominal typing: Brand<string, 'Postcode'> is assignable from a plain string
only through an explicit cast — so a smart constructor is the only minter. */
export type Brand<T, B extends string> = T & { readonly __brand: B };

View File

@@ -0,0 +1,31 @@
import { Component, input } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { LinkComponent } from '@shared/ui/link/link.component';
/** Template: standard page body — 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 the header/footer chrome). */
@Component({
selector: 'app-page-shell',
imports: [HeadingComponent, LinkComponent],
styles: [':host{display:block}'],
template: `
<div [style.max-width]="width() === 'narrow' ? '32rem' : null">
@if (backLink()) {
<p><app-link [to]="backLink()!">← {{ backLabel() }}</app-link></p>
}
<app-heading [level]="1">{{ heading() }}</app-heading>
@if (intro()) {
<p class="rhc-paragraph" style="margin-bottom:1.5rem">{{ intro() }}</p>
}
<ng-content />
</div>
`,
})
export class PageShellComponent {
heading = input.required<string>();
intro = input<string>();
backLink = input<string>();
backLabel = input('Terug naar overzicht');
width = input<'default' | 'narrow'>('default');
}

View File

@@ -0,0 +1,34 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig, moduleMetadata } from '@storybook/angular';
import { provideRouter } from '@angular/router';
import { PageShellComponent } from './page-shell.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
const meta: Meta<PageShellComponent> = {
title: 'Templates/PageShell',
component: PageShellComponent,
decorators: [
applicationConfig({ providers: [provideRouter([])] }),
moduleMetadata({ imports: [ButtonComponent] }),
],
render: (args) => ({
props: args,
template: `
<app-page-shell [heading]="heading" [intro]="intro" [backLink]="backLink" [width]="width">
<p class="rhc-paragraph">Pagina-inhoud wordt hier geprojecteerd.</p>
<app-button variant="primary">Een actie</app-button>
</app-page-shell>`,
}),
};
export default meta;
type Story = StoryObj<PageShellComponent>;
export const Default: Story = {
args: { heading: 'Mijn BIG-registratie', intro: 'Overzicht van uw registratie.' },
};
export const WithBackLink: Story = {
args: { heading: 'Mijn gegevens', backLink: '/dashboard' },
};
export const Narrow: Story = {
args: { heading: 'Inloggen', width: 'narrow', intro: 'Log in op uw omgeving.' },
};

View File

@@ -0,0 +1,25 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { SiteHeaderComponent } from '@shared/layout/site-header/site-header.component';
import { SiteFooterComponent } from '@shared/layout/site-footer/site-footer.component';
/** Template: persistent app chrome. Header + footer mount once; only the routed
content inside <router-outlet> changes (and cross-fades — see styles.scss). */
@Component({
selector: 'app-shell',
imports: [RouterOutlet, SiteHeaderComponent, SiteFooterComponent],
styles: [':host{display:block}'],
template: `
<a href="#main" class="rhc-skip-link" style="position:absolute;left:-999px">Naar de inhoud</a>
<div class="utrecht-page-layout utrecht-page-layout--stretch" style="--app-content-max:64rem;min-height:100vh;align-items:stretch">
<app-site-header />
<main id="main" class="utrecht-page-content" style="flex:1;width:100%;box-sizing:border-box">
<div style="max-width:var(--app-content-max);margin:0 auto;padding:2rem 1.5rem;box-sizing:border-box">
<router-outlet />
</div>
</main>
<app-site-footer />
</div>
`,
})
export class ShellComponent {}

View File

@@ -0,0 +1,17 @@
import { Component } from '@angular/core';
/** Organism: site footer. */
@Component({
selector: 'app-site-footer',
styles: [':host{display:block}'],
template: `
<footer class="utrecht-page-footer" style="background:var(--rhc-color-lintblauw-900,#01689b);color:#fff;margin-top:3rem;width:100%">
<div style="max-width:var(--app-content-max);margin:0 auto;padding:1.5rem;display:flex;gap:2rem;flex-wrap:wrap;box-sizing:border-box">
<span>BIG-register</span>
<span>CIBG — Ministerie van Volksgezondheid, Welzijn en Sport</span>
<span style="margin-left:auto;opacity:0.85">Demo / POC — geen echte gegevens</span>
</div>
</footer>
`,
})
export class SiteFooterComponent {}

View File

@@ -0,0 +1,28 @@
import { Component, input } from '@angular/core';
import { RouterLink } from '@angular/router';
/** Organism: site header with Rijksoverheid-style wordmark + title.
ponytail: text wordmark instead of the licensed Rijksoverheid logo. */
@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).
styles: [':host{display:block}'],
template: `
<header class="utrecht-page-header" style="background:var(--rhc-color-lintblauw-700,#154273);color:#fff;width:100%">
<div style="display:flex;align-items:center;gap:1rem;max-width:var(--app-content-max);margin:0 auto;padding:1rem 1.5rem;box-sizing:border-box">
<a routerLink="/dashboard" style="display:flex;align-items:center;gap:0.75rem;color:inherit;text-decoration:none">
<span aria-hidden="true" style="display:inline-block;width:0.5rem;height:2.25rem;background:#fff"></span>
<span style="font-weight:700;line-height:1.1">
Rijksoverheid<br><span style="font-weight:400;font-size:0.9rem">BIG-register</span>
</span>
</a>
<span style="margin-left:auto;font-weight:500">{{ subtitle() }}</span>
</div>
</header>
`,
})
export class SiteHeaderComponent {
subtitle = input('Mijn omgeving');
}

View File

@@ -0,0 +1,22 @@
import { Component, input } from '@angular/core';
type AlertType = 'info' | 'ok' | 'warning' | 'error';
/** Atom: alert/message banner. */
@Component({
selector: 'app-alert',
template: `
<div
class="utrecht-alert rhc-alert"
[class.utrecht-alert--info]="type() === 'info'"
[class.utrecht-alert--ok]="type() === 'ok'"
[class.utrecht-alert--warning]="type() === 'warning'"
[class.utrecht-alert--error]="type() === 'error'"
role="status">
<ng-content />
</div>
`,
})
export class AlertComponent {
type = input<AlertType>('info');
}

View File

@@ -0,0 +1,18 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { AlertComponent } from './alert.component';
const meta: Meta<AlertComponent> = {
title: 'Atoms/Alert',
component: AlertComponent,
render: (args) => ({
props: args,
template: `<app-alert [type]="type">Uw wijziging is ontvangen.</app-alert>`,
}),
};
export default meta;
type Story = StoryObj<AlertComponent>;
export const Info: Story = { args: { type: 'info' } };
export const Ok: Story = { args: { type: 'ok' } };
export const Warning: Story = { args: { type: 'warning' } };
export const Error: Story = { args: { type: 'error' } };

View File

@@ -0,0 +1,107 @@
import { Component, Directive, TemplateRef, computed, contentChild, input } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import type { Resource } from '@angular/core';
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { RemoteData, fromResource, foldRemote } from '@shared/application/remote-data';
/* Slot markers. Put on <ng-template> children of <app-async>. */
@Directive({ selector: '[appAsyncLoaded]' })
export class AsyncLoadedDirective {
constructor(public tpl: TemplateRef<{ $implicit: unknown }>) {}
}
@Directive({ selector: '[appAsyncLoading]' })
export class AsyncLoadingDirective {
constructor(public tpl: TemplateRef<unknown>) {}
}
@Directive({ selector: '[appAsyncEmpty]' })
export class AsyncEmptyDirective {
constructor(public tpl: TemplateRef<unknown>) {}
}
@Directive({ selector: '[appAsyncError]' })
export class AsyncErrorDirective {
constructor(public tpl: TemplateRef<{ $implicit: Error | undefined; retry: () => void }>) {}
}
/**
* Renders exactly ONE of loading / empty / error / loaded for a signal-based
* resource (e.g. httpResource). Built on a RemoteData tagged union (see
* core/remote-data.ts), so the states are mutually exclusive by construction —
* the UI can never show two at once ("impossible states"). Unprovided slots
* fall back to sensible defaults.
*/
@Component({
selector: 'app-async',
imports: [NgTemplateOutlet, SpinnerComponent, AlertComponent, ButtonComponent],
template: `
@switch (rd().tag) {
@case ('Loading') {
@if (loadingTpl()) { <ng-container [ngTemplateOutlet]="loadingTpl()!.tpl" /> }
@else { <app-spinner /> }
}
@case ('Failure') {
@if (errorTpl()) {
<ng-container [ngTemplateOutlet]="errorTpl()!.tpl"
[ngTemplateOutletContext]="{ $implicit: error(), retry: retry }" />
} @else {
<app-alert type="error">Er ging iets mis bij het laden van de gegevens.</app-alert>
<div style="margin-top:1rem">
<app-button variant="secondary" (click)="retry()">Opnieuw proberen</app-button>
</div>
}
}
@case ('Empty') {
@if (emptyTpl()) { <ng-container [ngTemplateOutlet]="emptyTpl()!.tpl" /> }
@else { <p class="rhc-paragraph">Geen gegevens gevonden.</p> }
}
@case ('Success') {
<ng-container [ngTemplateOutlet]="loadedTpl().tpl"
[ngTemplateOutletContext]="{ $implicit: value() }" />
}
}
`,
})
export class AsyncComponent<T> {
// Two ways to feed this component:
// [resource] — a raw httpResource (the common case), or
// [data] — an already-combined RemoteData (e.g. from a store via map2).
resource = input<Resource<T>>();
data = input<RemoteData<Error | undefined, T>>();
isEmpty = input<(v: T) => boolean>(() => false);
loadedTpl = contentChild.required(AsyncLoadedDirective);
loadingTpl = contentChild(AsyncLoadingDirective);
emptyTpl = contentChild(AsyncEmptyDirective);
errorTpl = contentChild(AsyncErrorDirective);
// Single source of truth: the supplied RemoteData, or the resource projected into one.
protected rd = computed<RemoteData<Error | undefined, T>>(() => {
const data = this.data();
if (data) return data;
const r = this.resource();
return r ? fromResource(r, this.isEmpty()) : { tag: 'Loading' };
});
// value/error are pulled out via the exhaustive fold — only Success carries a
// value, only Failure carries an error, so these can't lie.
protected value = computed(() =>
foldRemote(this.rd(), { loading: () => undefined, empty: () => undefined, failure: () => undefined, success: (v) => v }),
);
protected error = computed(() =>
foldRemote(this.rd(), { loading: () => undefined, empty: () => undefined, failure: (e) => e, success: () => undefined }),
);
retry = () => {
const r = this.resource();
if (r && 'reload' in r && typeof (r as { reload?: unknown }).reload === 'function') {
(r as { reload: () => void }).reload();
}
};
}
/** Convenience: import this array to get the wrapper + all slot directives. */
export const ASYNC = [
AsyncComponent, AsyncLoadedDirective, AsyncLoadingDirective,
AsyncEmptyDirective, AsyncErrorDirective,
] as const;

View File

@@ -0,0 +1,43 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { moduleMetadata } from '@storybook/angular';
import type { Resource } from '@angular/core';
import { ASYNC } from './async.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
/** Minimal fake of a signal Resource so the wrapper can be driven through every
state in isolation (no HTTP). */
function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T> {
return {
value: () => value as T,
status: () => status,
error: () => error,
hasValue: () => value !== undefined,
reload: () => {},
} as unknown as Resource<T>;
}
const meta: Meta = {
title: 'Molecules/Async States',
decorators: [moduleMetadata({ imports: [...ASYNC, SkeletonComponent] })],
render: (args) => ({
// isEmpty is a function — Storybook strips function args, so set it here.
props: { ...args, isEmpty: (v: string[]) => !v || v.length === 0 },
template: `
<app-async [resource]="resource" [isEmpty]="isEmpty">
<ng-template appAsyncLoaded let-items>
<ul class="rhc-unordered-list">
@for (i of items; track i) { <li>{{ i }}</li> }
</ul>
</ng-template>
<ng-template appAsyncLoading><app-skeleton [count]="3" height="1.5rem" [delay]="0" /></ng-template>
<ng-template appAsyncEmpty><p class="rhc-paragraph">Geen items gevonden.</p></ng-template>
</app-async>`,
}),
};
export default meta;
type Story = StoryObj;
export const Loaded: Story = { args: { resource: fakeResource('resolved', ['Huisartsgeneeskunde', 'Spoedeisende hulp']) } };
export const Loading: Story = { args: { resource: fakeResource('loading') } };
export const Empty: Story = { args: { resource: fakeResource('resolved', [] as string[]) } };
export const ErrorState: Story = { args: { resource: fakeResource('error', undefined, new Error('Demo')) } };

View File

@@ -0,0 +1,26 @@
import { Component, input } from '@angular/core';
type Variant = 'primary' | 'secondary' | 'subtle' | 'danger';
/** Atom: button. Thin wrapper over the Utrecht/RHC button CSS. */
@Component({
selector: 'app-button',
template: `
<button
[type]="type()"
[disabled]="disabled()"
class="utrecht-button rhc-button"
[class.utrecht-button--primary-action]="variant() === 'primary'"
[class.utrecht-button--secondary-action]="variant() === 'secondary'"
[class.utrecht-button--subtle]="variant() === 'subtle'"
[class.utrecht-button--danger]="variant() === 'danger'"
[class.utrecht-button--disabled]="disabled()">
<ng-content />
</button>
`,
})
export class ButtonComponent {
variant = input<Variant>('primary');
type = input<'button' | 'submit'>('button');
disabled = input(false);
}

View File

@@ -0,0 +1,19 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { ButtonComponent } from './button.component';
const meta: Meta<ButtonComponent> = {
title: 'Atoms/Button',
component: ButtonComponent,
render: (args) => ({
props: args,
template: `<app-button [variant]="variant" [type]="type" [disabled]="disabled">Knop</app-button>`,
}),
};
export default meta;
type Story = StoryObj<ButtonComponent>;
export const Primary: Story = { args: { variant: 'primary' } };
export const Secondary: Story = { args: { variant: 'secondary' } };
export const Subtle: Story = { args: { variant: 'subtle' } };
export const Danger: Story = { args: { variant: 'danger' } };
export const Disabled: Story = { args: { variant: 'primary', disabled: true } };

View File

@@ -0,0 +1,17 @@
import { Component, input } from '@angular/core';
/** Molecule: one key/value row inside an RHC data-summary.
Wrap several of these in .rhc-data-summary (see registration-summary). */
@Component({
selector: 'app-data-row',
template: `
<div class="rhc-data-summary__item">
<dt class="rhc-data-summary__item-key">{{ key() }}</dt>
<dd class="rhc-data-summary__item-value"><ng-content>{{ value() }}</ng-content></dd>
</div>
`,
})
export class DataRowComponent {
key = input.required<string>();
value = input<string | null>('');
}

View File

@@ -0,0 +1,25 @@
import { Component, 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',
template: `
<div class="rhc-form-field utrecht-form-field" [class.utrecht-form-field--invalid]="!!error()">
<label class="utrecht-form-label" [for]="fieldId()">{{ label() }}</label>
@if (description()) {
<div class="utrecht-form-field-description">{{ description() }}</div>
}
<ng-content />
@if (error()) {
<div class="utrecht-form-field-error-message" role="alert">{{ error() }}</div>
}
</div>
`,
})
export class FormFieldComponent {
label = input.required<string>();
fieldId = input.required<string>();
description = input<string>();
error = input<string>();
}

View File

@@ -0,0 +1,26 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { moduleMetadata } from '@storybook/angular';
import { FormFieldComponent } from './form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
const meta: Meta<FormFieldComponent> = {
title: 'Molecules/Form Field',
component: FormFieldComponent,
decorators: [moduleMetadata({ imports: [TextInputComponent] })],
render: (args) => ({
props: args,
template: `
<app-form-field [label]="label" [fieldId]="fieldId" [description]="description" [error]="error">
<app-text-input [inputId]="fieldId" [invalid]="!!error" placeholder="Vul in" />
</app-form-field>`,
}),
};
export default meta;
type Story = StoryObj<FormFieldComponent>;
export const Default: Story = {
args: { label: 'BSN', fieldId: 'bsn', description: '9 cijfers' },
};
export const WithError: Story = {
args: { label: 'Straat en huisnummer', fieldId: 'street', error: 'Dit veld is verplicht.' },
};

View File

@@ -0,0 +1,23 @@
import { Component, input } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
/** Atom: heading. Renders the right h1..h5 with RHC heading styling.
Single <ng-content> captured in a template — multiple ng-content across
@switch branches silently drops the projected content. */
@Component({
selector: 'app-heading',
imports: [NgTemplateOutlet],
template: `
<ng-template #content><ng-content /></ng-template>
@switch (level()) {
@case (1) { <h1 class="rhc-heading nl-heading--level-1"><ng-container [ngTemplateOutlet]="content" /></h1> }
@case (2) { <h2 class="rhc-heading nl-heading--level-2"><ng-container [ngTemplateOutlet]="content" /></h2> }
@case (3) { <h3 class="rhc-heading nl-heading--level-3"><ng-container [ngTemplateOutlet]="content" /></h3> }
@case (4) { <h4 class="rhc-heading nl-heading--level-4"><ng-container [ngTemplateOutlet]="content" /></h4> }
@default { <h5 class="rhc-heading nl-heading--level-5"><ng-container [ngTemplateOutlet]="content" /></h5> }
}
`,
})
export class HeadingComponent {
level = input<1 | 2 | 3 | 4 | 5>(2);
}

View File

@@ -0,0 +1,17 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { HeadingComponent } from './heading.component';
const meta: Meta<HeadingComponent> = {
title: 'Atoms/Heading',
component: HeadingComponent,
render: (args) => ({
props: args,
template: `<app-heading [level]="level">Mijn BIG-registratie</app-heading>`,
}),
};
export default meta;
type Story = StoryObj<HeadingComponent>;
export const Level1: Story = { args: { level: 1 } };
export const Level2: Story = { args: { level: 2 } };
export const Level3: Story = { args: { level: 3 } };

View File

@@ -0,0 +1,12 @@
import { Component, input } from '@angular/core';
import { RouterLink } from '@angular/router';
/** Atom: link. Internal router link styled as an RHC/Utrecht link. */
@Component({
selector: 'app-link',
imports: [RouterLink],
template: `<a [routerLink]="to()" class="rhc-link nl-link utrecht-link"><ng-content /></a>`,
})
export class LinkComponent {
to = input.required<string>();
}

View File

@@ -0,0 +1,33 @@
import { Component, OnDestroy, OnInit, computed, input, signal } from '@angular/core';
/** Atom: skeleton placeholder (grey shimmer). Delay-gated so it never flashes
on fast responses. Render `count` lines shaped roughly like the content. */
@Component({
selector: 'app-skeleton',
styles: [`
:host{display:block}
.sk{background:linear-gradient(90deg,#e8ebee 25%,#f3f5f6 37%,#e8ebee 63%);
background-size:400% 100%;animation:sh 1.4s ease infinite;border-radius:4px;
margin-block-end:0.6rem}
@keyframes sh{0%{background-position:100% 0}100%{background-position:0 0}}
`],
template: `
@if (visible()) {
@for (l of lines(); track $index) {
<div class="sk" [style.width]="width()" [style.height]="height()"></div>
}
}
`,
})
export class SkeletonComponent implements OnInit, OnDestroy {
width = input('100%');
height = input('1rem');
count = input(1);
delay = input(150);
protected visible = signal(false);
protected lines = computed(() => Array(this.count()).fill(0));
private timer?: ReturnType<typeof setTimeout>;
ngOnInit() { this.timer = setTimeout(() => this.visible.set(true), this.delay()); }
ngOnDestroy() { clearTimeout(this.timer); }
}

View File

@@ -0,0 +1,13 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { SkeletonComponent } from './skeleton.component';
const meta: Meta<SkeletonComponent> = {
title: 'Atoms/Skeleton',
component: SkeletonComponent,
args: { delay: 0 },
};
export default meta;
type Story = StoryObj<SkeletonComponent>;
export const SingleLine: Story = { args: { width: '60%', height: '1rem' } };
export const CardPlaceholder: Story = { args: { height: '2.5rem', count: 6 } };

View File

@@ -0,0 +1,28 @@
import { Component, OnDestroy, OnInit, input, signal } from '@angular/core';
/** Atom: spinner that only appears after `delay` ms — fast responses never
flash a spinner, slow ones get feedback. */
@Component({
selector: 'app-spinner',
styles: [`
:host{display:block}
.sp{width:2rem;height:2rem;border-radius:50%;
border:3px solid var(--rhc-color-grijs-300,#cad0d6);
border-block-start-color:var(--rhc-color-lintblauw-700,#154273);
animation:sp 0.8s linear infinite;margin:1.5rem auto}
@keyframes sp{to{transform:rotate(360deg)}}
`],
template: `
@if (visible()) {
<div class="sp" role="status" aria-label="Bezig met laden"></div>
}
`,
})
export class SpinnerComponent implements OnInit, OnDestroy {
delay = input(250);
protected visible = signal(false);
private timer?: ReturnType<typeof setTimeout>;
ngOnInit() { this.timer = setTimeout(() => this.visible.set(true), this.delay()); }
ngOnDestroy() { clearTimeout(this.timer); }
}

View File

@@ -0,0 +1,12 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { SpinnerComponent } from './spinner.component';
const meta: Meta<SpinnerComponent> = {
title: 'Atoms/Spinner',
component: SpinnerComponent,
};
export default meta;
type Story = StoryObj<SpinnerComponent>;
// delay 0 so it shows immediately in the story
export const Default: Story = { args: { delay: 0 } };

View File

@@ -0,0 +1,18 @@
import { Component, input } from '@angular/core';
/** Atom: a coloured dot + label. Purely presentational and domain-free — the
caller decides what colour and label mean (e.g. via registration.policy).
This keeps the shared UI kernel free of any domain knowledge. */
@Component({
selector: 'app-status-badge',
template: `
<span style="display:inline-flex;align-items:center;gap:0.5rem">
<span class="rhc-dot-badge" [style.background-color]="color()" aria-hidden="true"></span>
<span>{{ label() }}</span>
</span>
`,
})
export class StatusBadgeComponent {
label = input.required<string>();
color = input.required<string>();
}

View File

@@ -0,0 +1,13 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { StatusBadgeComponent } from './status-badge.component';
const meta: Meta<StatusBadgeComponent> = {
title: 'Shared UI/Status Badge',
component: StatusBadgeComponent,
};
export default meta;
type Story = StoryObj<StatusBadgeComponent>;
export const Geregistreerd: Story = { args: { label: 'Geregistreerd', color: 'var(--rhc-color-groen-500)' } };
export const Geschorst: Story = { args: { label: 'Geschorst', color: 'var(--rhc-color-oranje-500)' } };
export const Doorgehaald: Story = { args: { label: 'Doorgehaald', color: 'var(--rhc-color-rood-500)' } };

View File

@@ -0,0 +1,40 @@
import { Component, forwardRef, input } from '@angular/core';
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',
template: `
<input
class="utrecht-textbox rhc-text-input"
[class.utrecht-textbox--invalid]="invalid()"
[type]="type()"
[id]="inputId()"
[placeholder]="placeholder()"
[disabled]="disabled"
[value]="value"
(input)="onInput($event)"
(blur)="onTouched()" />
`,
providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => TextInputComponent), multi: true }],
})
export class TextInputComponent implements ControlValueAccessor {
type = input<'text' | 'password' | 'email'>('text');
placeholder = input('');
invalid = input(false);
inputId = input<string>();
value = '';
disabled = false;
onChange: (v: string) => void = () => {};
onTouched: () => void = () => {};
onInput(e: Event) {
this.value = (e.target as HTMLInputElement).value;
this.onChange(this.value);
}
writeValue(v: string) { this.value = v ?? ''; }
registerOnChange(fn: (v: string) => void) { this.onChange = fn; }
registerOnTouched(fn: () => void) { this.onTouched = fn; }
setDisabledState(d: boolean) { this.disabled = d; }
}