feat(WP-67): merge behandelportal into this repo as a monorepo

Restructures into apps/ssp + apps/behandelportal (two Angular projects)
plus libs/shared + libs/beheer (cross-app libraries), replacing WP-61's
separate sibling repo. That split had already produced real drift: a
hand-vendored copy of the backend's OpenAPI doc, a shared/ui+layout tree
forked and silently diverging (7 files), and beheer + the styles.scss
token bridge duplicated byte-for-byte across both repos.

- git mv the SSP's src/app/* into apps/ssp/; fold shared/, beheer/,
  environments/, the Storybook docs/*.mdx, and styles.scss into
  libs/shared + libs/beheer (all confirmed identical between the two
  repos before merging). auth stays deliberately duplicated per
  ADR-0002 (actor-specific, expected to diverge) - amended there.
- One generated API client (libs/shared), no more vendored swagger.json.
- .dependency-cruiser split into a base factory + one config per app,
  and Storybook into .storybook-ssp/.storybook-behandelportal - both
  forced by the @auth/* alias resolving to different directories per app.
- SiteHeaderComponent/ShellComponent gained HEADER_NAV_ITEMS/
  HEADER_ADMIN_LINKS/DEBUG_PANEL injection tokens so each app supplies
  its own nav/admin-links/dev-panel instead of one being hardcoded.
- CLAUDE.md, ARCHITECTURE.md, dependencies.md, and ADR-0002 updated;
  WP-67 backlog entry documents the full decision trail.

npm run ci green (lint, dep:check x2, 360 tests across ssp/
behandelportal/shared/beheer, both localized builds, backend tests,
snippet + api-client drift); both dev servers, both Storybook
instances, and docker compose verified working.

The old sibling repo (/home/eho/repos/behandelportal) is left
untouched, not deleted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-02 21:01:57 +02:00
co-authored by Claude Sonnet 5
parent d3f3b13345
commit e7156c5132
403 changed files with 7103 additions and 60917 deletions
@@ -0,0 +1,54 @@
import { Injectable, computed, inject } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { filter, firstValueFrom } from 'rxjs';
import { RemoteData, fromResource } from '@shared/application/remote-data';
import { Capability } from '@shared/domain/capability';
import { MeAdapter, parseMe } from '@shared/infrastructure/me.adapter';
type Err = Error | undefined;
/**
* The current principal's capabilities (PRD-0002 §6) — one root singleton, like
* `SessionStore`/`BigProfileStore`. Global capabilities load once from `GET /me`;
* a screen's own decision DTO (e.g. `BriefViewDto.decisions`) covers anything tied
* to a specific resource's live status — no extra round-trip needed for that.
*
* `can()` is deny-by-default: loading, failed, or an unrecognized capability all
* resolve to `false`. This store never derives a capability from a role — it only
* mirrors what the server already resolved.
*/
@Injectable({ providedIn: 'root' })
export class AccessStore {
private adapter = inject(MeAdapter);
private meRes = this.adapter.meResource();
private capabilities = computed<RemoteData<Err, Capability[]>>(() => {
const rd = fromResource(this.meRes);
if (rd.tag !== 'Success') return rd;
const parsed = parseMe(rd.value);
return parsed.ok
? { tag: 'Success', value: parsed.value }
: { tag: 'Failure', error: new Error(parsed.error) };
});
can(capability: Capability): boolean {
const rd = this.capabilities();
return rd.tag === 'Success' && rd.value.includes(capability);
}
/** True once `/me` has resolved (success or failure) — lets a page-level gate tell
"still loading" apart from "denied", so an admin doesn't flash the denial alert. */
readonly ready = computed(() => {
const tag = this.capabilities().tag;
return tag === 'Success' || tag === 'Failure';
});
private ready$ = toObservable(this.ready);
/** Resolves once `/me` has settled (success or failure). The `capabilityGuard` awaits
this before deciding — otherwise it reads `can()` while `/me` is still loading and
wrongly denies (deny-by-default), bouncing even an entitled user. */
async whenReady(): Promise<void> {
if (this.ready()) return;
await firstValueFrom(this.ready$.pipe(filter((r) => r)));
}
}
@@ -0,0 +1,9 @@
/** Transient state of a one-shot action (submit/approve/publish/reset/…): one tagged
union instead of a busy boolean + a nullable error sitting side by side. Shared by the
editor stores (WP-31). */
export type ActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string };
/** Debounced-autosave indicator, shown in a small status line near a toolbar — a separate
concern from ActionState (a stale autosave error doesn't block submit/approve), but
tag-aligned with it for one consistent idiom. */
export type SaveState = { tag: 'Idle' } | { tag: 'Saving' } | { tag: 'Saved' } | { tag: 'Error' };
@@ -0,0 +1,56 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createDebouncedSave } from './debounced-save';
describe('createDebouncedSave', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('flushes after the delay when canSave is true', async () => {
const flush = vi.fn().mockResolvedValue(undefined);
const d = createDebouncedSave({ delayMs: 600, canSave: () => true, flush });
d.schedule();
expect(d.hasPendingSave()).toBe(true);
expect(flush).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(600);
expect(flush).toHaveBeenCalledTimes(1);
expect(d.hasPendingSave()).toBe(false);
});
it('does not schedule when canSave is false', () => {
const flush = vi.fn().mockResolvedValue(undefined);
const d = createDebouncedSave({ canSave: () => false, flush });
d.schedule();
expect(d.hasPendingSave()).toBe(false);
});
it('coalesces rapid schedules into a single flush', async () => {
const flush = vi.fn().mockResolvedValue(undefined);
const d = createDebouncedSave({ delayMs: 100, canSave: () => true, flush });
d.schedule();
d.schedule();
d.schedule();
await vi.advanceTimersByTimeAsync(100);
expect(flush).toHaveBeenCalledTimes(1);
});
it('flushPending runs the save immediately and clears; no-op when idle', async () => {
const flush = vi.fn().mockResolvedValue(undefined);
const d = createDebouncedSave({ delayMs: 600, canSave: () => true, flush });
await d.flushPending();
expect(flush).not.toHaveBeenCalled(); // idle
d.schedule();
await d.flushPending();
expect(flush).toHaveBeenCalledTimes(1);
expect(d.hasPendingSave()).toBe(false);
});
it('cancel drops a scheduled save without running it', async () => {
const flush = vi.fn().mockResolvedValue(undefined);
const d = createDebouncedSave({ delayMs: 600, canSave: () => true, flush });
d.schedule();
d.cancel();
expect(d.hasPendingSave()).toBe(false);
await vi.advanceTimersByTimeAsync(600);
expect(flush).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,49 @@
export interface DebouncedSave {
/** (Re)arm the debounce timer; no-op when `canSave()` is false. */
schedule(): void;
/** True while a scheduled save hasn't run yet — implements `PendingSave.hasPendingSave`. */
hasPendingSave(): boolean;
/** Run a scheduled save now and await it; no-op when nothing is scheduled. */
flushPending(): Promise<void>;
/** Drop a scheduled save without running it (e.g. before an authoritative transition,
which flushes explicitly, or a reset that discards the draft). */
cancel(): void;
}
/**
* The debounced-autosave timer shared by the editor stores (WP-31). It owns ONLY the timer
* bookkeeping; the actual write + save-state transitions live in the caller's `flush`
* (store-specific — it touches that store's SaveState/ActionState + adapter). The handle is
* nulled the moment it fires, so `hasPendingSave()` means "a write is still owed". Integrates
* with the `PendingSave` seam (pending-saves.ts): a store delegates hasPendingSave/flushPending
* here so the CanDeactivate guard / beforeunload handler can flush a pending edit.
*/
export function createDebouncedSave(opts: {
delayMs?: number;
canSave: () => boolean;
flush: () => Promise<void>;
}): DebouncedSave {
const delay = opts.delayMs ?? 600;
let timer: ReturnType<typeof setTimeout> | undefined;
return {
schedule() {
if (!opts.canSave()) return;
clearTimeout(timer);
timer = setTimeout(() => {
timer = undefined;
void opts.flush();
}, delay);
},
hasPendingSave: () => timer !== undefined,
async flushPending() {
if (timer === undefined) return;
clearTimeout(timer);
timer = undefined;
await opts.flush();
},
cancel() {
clearTimeout(timer);
timer = undefined;
},
};
}
@@ -0,0 +1,58 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { RemoteData } from '@shared/application/remote-data';
import { FeatureFlag } from '@shared/domain/feature-flag';
import { FeatureFlagsAdapter, parseFlags } from '@shared/infrastructure/feature-flags.adapter';
type Err = Error | undefined;
/**
* Runtime feature-flag state (WP-47) — one root singleton, mirroring `AccessStore`. Loads the
* resolved flag set once from `GET /flags`; `enabled(key)` gates a feature (deny-by-default:
* false until loaded / unknown key). `set()` is the admin toggle (PUT + reload). The catalog is
* server-owned; the FE only mirrors + renders it.
*/
@Injectable({ providedIn: 'root' })
export class FeatureFlagStore {
private adapter = inject(FeatureFlagsAdapter);
private state = signal<RemoteData<Err, FeatureFlag[]>>({ tag: 'Loading' });
readonly flags = this.state.asReadonly();
/** The resolved list (empty until loaded) — for the admin toggle UI. */
readonly all = computed(() => {
const rd = this.state();
return rd.tag === 'Success' ? rd.value : [];
});
constructor() {
void this.load();
}
async load() {
if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' });
try {
const parsed = parseFlags(await this.adapter.list());
this.state.set(
parsed.ok
? { tag: 'Success', value: parsed.value }
: { tag: 'Failure', error: new Error(parsed.error) },
);
} catch (e) {
this.state.set({ tag: 'Failure', error: e as Error });
}
}
/** Deny-by-default: false while loading/failed or for an unknown key. Reactive (reads the signal). */
enabled(key: string): boolean {
const rd = this.state();
return rd.tag === 'Success' && (rd.value.find((f) => f.key === key)?.enabled ?? false);
}
/** Admin toggle: persist then reload so the state reflects the server. */
async set(key: string, enabled: boolean) {
try {
await this.adapter.set(key, enabled);
} finally {
await this.load();
}
}
}
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest';
import { createHistory } from './history';
describe('createHistory', () => {
it('starts empty; undo/redo are no-ops', () => {
const h = createHistory<number>();
expect(h.canUndo()).toBe(false);
expect(h.canRedo()).toBe(false);
expect(h.undo(1)).toBeUndefined();
expect(h.redo(1)).toBeUndefined();
});
it('records pre-edit snapshots, then undoes and redoes through them', () => {
const h = createHistory<string>();
// document went a -> b (record a) -> c (record b); current is 'c'
h.record('a');
h.record('b');
expect(h.canUndo()).toBe(true);
expect(h.undo('c')).toBe('b'); // current 'c' pushed to redo
expect(h.canRedo()).toBe(true);
expect(h.undo('b')).toBe('a');
expect(h.canUndo()).toBe(false);
expect(h.redo('a')).toBe('b');
expect(h.redo('b')).toBe('c');
expect(h.canRedo()).toBe(false);
});
it('record() clears the redo stack (no dead redo after a fresh edit)', () => {
const h = createHistory<string>();
h.record('a');
h.undo('b'); // redo now holds 'b'
expect(h.canRedo()).toBe(true);
h.record('x');
expect(h.canRedo()).toBe(false);
});
it('caps the stack depth', () => {
const h = createHistory<number>(3);
for (let i = 0; i < 5; i++) h.record(i);
let undos = 0;
let cur = 99;
while (h.canUndo()) {
cur = h.undo(cur)!;
undos++;
}
expect(undos).toBe(3);
});
it('clear() empties both stacks', () => {
const h = createHistory<number>();
h.record(1);
h.undo(2);
h.clear();
expect(h.canUndo()).toBe(false);
expect(h.canRedo()).toBe(false);
});
});
+53
View File
@@ -0,0 +1,53 @@
import { Signal, computed, signal } from '@angular/core';
export interface History<T> {
readonly canUndo: Signal<boolean>;
readonly canRedo: Signal<boolean>;
/** Push a pre-edit snapshot onto the undo stack and drop the redo stack. */
record(snapshot: T): void;
/** Undo: pop the last recorded snapshot and return it (moving `current` onto the redo
stack); returns undefined and changes nothing when there's nothing to undo. */
undo(current: T): T | undefined;
/** Redo: mirror of undo. */
redo(current: T): T | undefined;
clear(): void;
}
/**
* Generic undo/redo history over an immutable "document" value `T`. Elm-store editors
* restore a returned snapshot by re-dispatching a `Seed`-style Msg — this helper only
* shuffles references, it never mutates them, so the caller must hold copy-on-write state
* (every edit produces a fresh value). Both stacks are capped so a long session can't grow
* unbounded. Extracted from BriefStore's WP-27 undo/redo (WP-31); reused by the stamdata
* editor (WP-32).
*/
export function createHistory<T>(cap = 50): History<T> {
const past = signal<readonly T[]>([]);
const future = signal<readonly T[]>([]);
return {
canUndo: computed(() => past().length > 0),
canRedo: computed(() => future().length > 0),
record(snapshot) {
past.update((p) => [...p, snapshot].slice(-cap));
future.set([]);
},
undo(current) {
const p = past();
if (p.length === 0) return undefined;
past.set(p.slice(0, -1));
future.update((f) => [...f, current].slice(-cap));
return p[p.length - 1];
},
redo(current) {
const f = future();
if (f.length === 0) return undefined;
future.set(f.slice(0, -1));
past.update((p) => [...p, current].slice(-cap));
return f[f.length - 1];
},
clear() {
past.set([]);
future.set([]);
},
};
}
@@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest';
import { machineRemoteData } from './machine-remote-data';
describe('machineRemoteData', () => {
it('maps loading → Loading', () => {
expect(machineRemoteData({ tag: 'loading' })).toEqual({ tag: 'Loading' });
});
it('maps failed → Failure carrying an Error with the reason', () => {
const rd = machineRemoteData({ tag: 'failed', reason: 'boom' });
expect(rd.tag).toBe('Failure');
if (rd.tag === 'Failure') expect(rd.error.message).toBe('boom');
});
it('maps loaded → Success carrying the whole loaded state', () => {
const loaded = { tag: 'loaded', foo: 42 } as const;
expect(machineRemoteData(loaded)).toEqual({ tag: 'Success', value: loaded });
});
});
@@ -0,0 +1,24 @@
import { RemoteData } from '@shared/application/remote-data';
/** The standard load-lifecycle tags an editor machine exposes. */
export type LoadLifecycle =
{ tag: 'loading' } | { tag: 'failed'; reason: string } | { tag: 'loaded' };
/**
* Project an Elm-machine state onto `RemoteData` for the `<app-async>` seam. The machine
* keeps owning its own domain lifecycle (draft/submitted/…); this is purely the
* loading/failed/loaded → async mapping, which was byte-identical across BriefStore,
* OrgTemplateStore and StamdataStore (WP-31). Wrap the call in a `computed`.
*/
export function machineRemoteData<S extends LoadLifecycle>(
s: S,
): RemoteData<Error, Extract<S, { tag: 'loaded' }>> {
switch (s.tag) {
case 'loading':
return { tag: 'Loading' };
case 'failed':
return { tag: 'Failure', error: new Error(s.reason) };
default: // 'loaded'
return { tag: 'Success', value: s as Extract<S, { tag: 'loaded' }> };
}
}
@@ -0,0 +1,86 @@
import { describe, it, expect, vi } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { PendingSave, PendingSaves, flushPendingGuard } from './pending-saves';
/** A fake autosave owner whose pending-ness and flush are controllable. */
function fakeOwner(pending: boolean): PendingSave & { flushPending: ReturnType<typeof vi.fn> } {
return {
hasPendingSave: () => pending,
flushPending: vi.fn().mockResolvedValue(undefined),
};
}
describe('PendingSaves registry', () => {
it('hasPending is true only while some registered owner has a pending write', () => {
const reg = new PendingSaves();
const idle = fakeOwner(false);
reg.register(idle);
expect(reg.hasPending()).toBe(false);
const dirty = fakeOwner(true);
reg.register(dirty);
expect(reg.hasPending()).toBe(true);
});
it('unregister removes an owner so it no longer counts', () => {
const reg = new PendingSaves();
const dirty = fakeOwner(true);
const off = reg.register(dirty);
expect(reg.hasPending()).toBe(true);
off();
expect(reg.hasPending()).toBe(false);
});
it('flushAll flushes only the pending owners', async () => {
const reg = new PendingSaves();
const idle = fakeOwner(false);
const dirty = fakeOwner(true);
reg.register(idle);
reg.register(dirty);
await reg.flushAll();
expect(dirty.flushPending).toHaveBeenCalledTimes(1);
expect(idle.flushPending).not.toHaveBeenCalled();
});
it('flushAll awaits every owner and swallows a rejected flush', async () => {
const reg = new PendingSaves();
const failing = fakeOwner(true);
failing.flushPending.mockRejectedValue(new Error('save failed'));
const ok = fakeOwner(true);
reg.register(failing);
reg.register(ok);
await expect(reg.flushAll()).resolves.toBeUndefined(); // never rejects
expect(ok.flushPending).toHaveBeenCalledTimes(1);
});
});
describe('flushPendingGuard', () => {
it('flushes then allows navigation when a write is pending', async () => {
const dirty = fakeOwner(true);
TestBed.configureTestingModule({});
const reg = TestBed.inject(PendingSaves);
reg.register(dirty);
const result = TestBed.runInInjectionContext(() =>
// the guard ignores its route args
(flushPendingGuard as (...a: unknown[]) => boolean | Promise<boolean>)(),
);
await expect(result).resolves.toBe(true);
expect(dirty.flushPending).toHaveBeenCalledTimes(1);
});
it('allows navigation immediately when nothing is pending', () => {
TestBed.configureTestingModule({});
TestBed.inject(PendingSaves).register(fakeOwner(false));
const result = TestBed.runInInjectionContext(() =>
(flushPendingGuard as (...a: unknown[]) => boolean | Promise<boolean>)(),
);
expect(result).toBe(true); // synchronous, not a Promise
});
});
@@ -0,0 +1,80 @@
import { DestroyRef, ENVIRONMENT_INITIALIZER, Injectable, inject } from '@angular/core';
import { CanDeactivateFn } from '@angular/router';
/**
* A source of debounced, not-yet-flushed writes (autosave). The two autosave owners in
* this app have different lifetimes — root singleton stores (`BriefStore`,
* `OrgTemplateStore`) and per-wizard `createDraftSync` controllers living inside child
* organisms — so both register here instead of the guard/unload handler needing to know
* which page or store owns the pending write.
*/
export interface PendingSave {
/** True while a debounced edit hasn't been written to the backend yet. */
hasPendingSave(): boolean;
/** Flush that pending write now and await it. No-op when nothing is pending. */
flushPending(): Promise<void>;
}
/** Registry of every active autosave owner. The `CanDeactivate` guard and the
`beforeunload` handler flush through this — one seam, both callers. */
@Injectable({ providedIn: 'root' })
export class PendingSaves {
private readonly owners = new Set<PendingSave>();
/** Register an owner; returns an unregister function. */
register(owner: PendingSave): () => void {
this.owners.add(owner);
return () => this.owners.delete(owner);
}
hasPending(): boolean {
return [...this.owners].some((o) => o.hasPendingSave());
}
/** Flush every owner that has a pending write, awaiting all. Best-effort: a rejected
flush is swallowed (a failed autosave surfaces its own error state; navigation must
not be blocked by it). */
async flushAll(): Promise<void> {
await Promise.allSettled(
[...this.owners].filter((o) => o.hasPendingSave()).map((o) => o.flushPending()),
);
}
}
/** Register the current injection context's owner for the life of its `DestroyRef`.
Call from a constructor or field initializer (root store, or `createDraftSync`). */
export function registerPendingSave(owner: PendingSave): void {
const unregister = inject(PendingSaves).register(owner);
inject(DestroyRef).onDestroy(unregister);
}
/** `CanDeactivate` guard: flush any pending debounced write before an in-app route change,
then allow navigation. Awaitable, so the write lands before the page tears down (which
would otherwise drop a sub-debounce edit). We never block leaving — the flush is a
guarantee of effort, not a gate. */
export const flushPendingGuard: CanDeactivateFn<unknown> = () => {
const pending = inject(PendingSaves);
return pending.hasPending() ? pending.flushAll().then(() => true) : true;
};
/** Wire a `beforeunload` handler that guards the last-mile save on a hard tab-close/reload.
ponytail: the HTTP seam is Angular `HttpClient` (no `keepalive`/`sendBeacon`), so an
async flush can't be guaranteed to finish as the page tears down — we fire it best-effort
AND trigger the browser's native "unsaved changes" prompt, which lets the ~600ms debounce
land if the user stays. Upgrade path: a `sendBeacon`/keepalive last-mile if this ever
needs to be guaranteed. */
export function provideUnloadFlush() {
return {
provide: ENVIRONMENT_INITIALIZER,
multi: true,
useValue: () => {
const pending = inject(PendingSaves);
window.addEventListener('beforeunload', (e) => {
if (!pending.hasPending()) return;
void pending.flushAll();
e.preventDefault();
e.returnValue = '';
});
},
};
}
@@ -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 });
});
});
@@ -0,0 +1,82 @@
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' };
}
// #region showcase:fold
/** 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); // add a variant → compile error until handled
}
}
// #endregion showcase:fold
// --- 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) };
}
/** 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;
}
@@ -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');
+32
View File
@@ -0,0 +1,32 @@
import { ApplicationRef, effect } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { describe, expect, it } from 'vitest';
import { createStore } from './store';
describe('createStore', () => {
it('applies the pure update on dispatch', () => {
const store = createStore(0, (n: number, m: number) => n + m);
store.dispatch(5);
store.dispatch(3);
expect(store.model()).toBe(8);
});
// Regression: an effect that dispatches must NOT re-run because of its own write.
// dispatch used to read `model()` reactively (`set(update(model(), msg))`), so an
// effect calling dispatch subscribed to `model` and looped forever, livelocking the
// main thread (crashed the upload wizards). With `.update` the read is untracked.
it('dispatch from inside an effect does not self-loop', () => {
const store = createStore(0, (n: number, _m: 'inc') => n + 1);
let runs = 0;
TestBed.runInInjectionContext(() => {
effect(() => {
runs++;
if (runs < 100) store.dispatch('inc'); // bounded so the buggy version can't hang the test
});
});
TestBed.inject(ApplicationRef).tick(); // flush effects
expect(runs).toBe(1); // effect ran once; its own dispatch did not retrigger it
expect(store.model()).toBe(1);
});
});
+33
View File
@@ -0,0 +1,33 @@
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(),
// Use `.update` (raw current value, no tracked read) not `set(update(model(), …))`:
// dispatch is a command and must never subscribe its caller to `model`. Reading
// `model()` here inside an effect that also dispatches makes the effect depend on
// its own write and livelock the main thread (crashed the upload wizards).
dispatch: (msg) => model.update((m) => update(m, msg)),
};
}
@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { runSubmit } from './submit';
describe('runSubmit', () => {
it('folds a resolved call into ok(value)', async () => {
const r = await runSubmit(async () => 'BIG-123', 'fallback');
expect(r).toEqual({ ok: true, value: 'BIG-123' });
});
it('maps a ProblemDetails rejection to err(detail)', async () => {
const r = await runSubmit(async () => {
throw { detail: 'Aanvraag afgewezen.' };
}, 'fallback');
expect(r).toEqual({ ok: false, error: 'Aanvraag afgewezen.' });
});
it('falls back when the rejection has no detail', async () => {
const r = await runSubmit(async () => {
throw new Error('network');
}, 'fallback');
expect(r).toEqual({ ok: false, error: 'fallback' });
});
});
+28
View File
@@ -0,0 +1,28 @@
import { Result, ok, err } from '@shared/kernel/fp';
import { problemDetail } from '@shared/infrastructure/api-error';
import { withIdempotencyKey } from '@shared/infrastructure/api-client.provider';
/**
* Run a mutating API call and fold it into a `Result` — the one place the
* try/catch + ProblemDetails-mapping lives, so every `submit-*` command is just
* its own payload mapping. The backend re-validates and returns a 422
* ProblemDetails on rejection, surfaced here as the error string.
*
* Also the one place a logical submit's Idempotency-Key is minted — once per
* `runSubmit` call, not per HTTP attempt — so a retry of this same submit
* dedupes on the backend (see `withIdempotencyKey`).
*/
export async function runSubmit<T>(
fn: () => Promise<T>,
fallback: string,
): Promise<Result<string, T>> {
try {
return ok(await withIdempotencyKey(crypto.randomUUID(), fn));
} catch (e) {
return err(problemDetail(e, fallback));
}
}
// Single shared default for a failed submit; the @@id dedupes it at the
// translation layer.
export const SUBMIT_FAILED = $localize`:@@submit.failed:Het indienen is niet gelukt. Probeer het later opnieuw.`;
+12
View File
@@ -0,0 +1,12 @@
/**
* A stable, namespaced capability string (PRD-0002 §5a), e.g. `brief:approve`.
* Server-resolved and opaque to the FE — never derived from a role client-side.
*/
export type Capability =
| 'brief:approve'
| 'brief:reject'
| 'brief:send'
| 'orgtemplate:edit'
| 'stamdata:edit'
| 'cases:manage'
| 'flags:manage';
+9
View File
@@ -0,0 +1,9 @@
/** A runtime feature flag as the FE sees it (resolved: catalog default + admin override). */
export interface FeatureFlag {
key: string;
description: string;
enabled: boolean;
}
/** Known flag keys the FE gates on — must match the backend `FeatureFlags` catalog. */
export const FLAG_INSCHRIJVING_OPEN = 'inschrijving-open';
+8
View File
@@ -0,0 +1,8 @@
/**
* The letter workflow's acting role: drafter or approver for the two-person
* compose/review flow, admin for org-template management (WP-23, Brief v2).
* A pure domain type (no framework, no reading mechanism) — the `?role=` reader and
* the X-Role header live in shared/infrastructure/role.ts. Consumers (brief.store,
* letter-composer) depend on this type, not on how the role is obtained.
*/
export type Role = 'drafter' | 'approver' | 'admin';
@@ -0,0 +1,10 @@
/**
* Production environment. `apiBaseUrl` stays relative ('') for a same-origin /
* reverse-proxy deployment; set it to the API origin (e.g. 'https://api.example.nl')
* when the SPA and backend are served from different hosts. This is the single
* place the deployed API location is configured.
*/
export const environment = {
production: true,
apiBaseUrl: '',
};
@@ -0,0 +1,10 @@
/**
* Default (development) environment. `apiBaseUrl` is empty so requests are
* relative to the current origin — in dev the ng-serve proxy forwards /api to the
* backend (proxy.conf.json). Swapped for environment.prod.ts in production builds
* (angular.json fileReplacements).
*/
export const environment = {
production: false,
apiBaseUrl: '',
};
@@ -0,0 +1,85 @@
import { describe, it, expect } from 'vitest';
import { of, throwError } from 'rxjs';
import { HttpClient, HttpErrorResponse, HttpResponse } from '@angular/common/http';
import { currentIdempotencyKey, httpClientFetch, withIdempotencyKey } from './api-client.provider';
/** Minimal stand-in for HttpClient — only `.request(...)` is ever called by the
* adapter under test, so no TestBed/HttpClientTestingModule needed. */
function fakeHttpClient(
request: (method: string, url: string, options: { headers: Record<string, string> }) => unknown,
): HttpClient {
return { request } as unknown as HttpClient;
}
describe('withIdempotencyKey / currentIdempotencyKey', () => {
it('threads the key to every read made inside the wrapped fn', async () => {
const seen: string[] = [];
await withIdempotencyKey('fixed-key', async () => {
seen.push(currentIdempotencyKey());
seen.push(currentIdempotencyKey());
});
expect(seen).toEqual(['fixed-key', 'fixed-key']);
});
it('clears the key once the wrapped fn settles', async () => {
await withIdempotencyKey('fixed-key', async () => undefined);
expect(currentIdempotencyKey()).not.toBe('fixed-key');
});
it('falls back to a generated uuid-shaped key when none is pending', () => {
expect(currentIdempotencyKey()).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
);
});
});
describe('httpClientFetch', () => {
it('sends the pending idempotency key as a header for a write, not a fresh one per attempt', async () => {
let sentHeaders: Record<string, string> | undefined;
const http = fakeHttpClient((_method, _url, opts) => {
sentHeaders = opts.headers;
return of(new HttpResponse({ status: 200, body: '' }));
});
await withIdempotencyKey('logical-submit-key', () =>
httpClientFetch(http).fetch('/api/v1/change-requests', { method: 'POST' }),
);
expect(sentHeaders?.['Idempotency-Key']).toBe('logical-submit-key');
});
// `http.request(...)` itself is only called once per `fetch()` — it returns a
// cold Observable, and `retry` resubscribes to *that*, not to `.request()`
// again (exactly how Angular's real HttpClient triggers a fresh network call
// per subscription). So attempts are counted where the resubscription lands:
// the `throwError` factory, not the outer mock call.
it('retries a failing GET twice before giving up', async () => {
let attempts = 0;
const http = fakeHttpClient(() =>
throwError(() => {
attempts++;
return new HttpErrorResponse({ status: 500 });
}),
);
const res = await httpClientFetch(http).fetch('/api/v1/notes', { method: 'GET' });
expect(attempts).toBe(3); // 1 original + 2 retries
expect(res.status).toBe(500);
});
it('never retries a failing write', async () => {
let attempts = 0;
const http = fakeHttpClient(() =>
throwError(() => {
attempts++;
return new HttpErrorResponse({ status: 500 });
}),
);
const res = await httpClientFetch(http).fetch('/api/v1/change-requests', { method: 'POST' });
expect(attempts).toBe(1);
expect(res.status).toBe(500);
});
});
@@ -0,0 +1,94 @@
import { Provider } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { firstValueFrom, retry, timeout, TimeoutError } from 'rxjs';
import { ApiClient, ProblemDetails } from './api-client';
import { environment } from '@shared/environments/environment';
/** Single place every API call passes through: the seam for cross-cutting concerns. */
const REQUEST_TIMEOUT_MS = 10_000;
/**
* A stable Idempotency-Key threaded down from the command layer (one per logical
* submit — see `runSubmit`) rather than minted per HTTP attempt, so a retried
* submit dedupes on the backend instead of double-submitting. The NSwag-generated
* `ApiClient` has no per-call header hook, so `withIdempotencyKey` bridges it here:
* every non-GET call made synchronously inside `fn` picks up the same key.
* ponytail: a module-level variable, not a proper async-context primitive — holds
* up because every submit command calls its adapter synchronously (no await
* before reaching this file); swap for `AsyncLocal`-equivalent if concurrent
* submits ever become possible.
*/
let pendingIdempotencyKey: string | undefined;
export function withIdempotencyKey<T>(key: string, fn: () => Promise<T>): Promise<T> {
pendingIdempotencyKey = key;
return fn().finally(() => (pendingIdempotencyKey = undefined));
}
export function currentIdempotencyKey(): string {
return pendingIdempotencyKey ?? crypto.randomUUID();
}
/**
* Adapts Angular's HttpClient to the fetch-shaped interface the NSwag-generated
* client expects, so every API call flows through HttpClient interceptors (the
* `?scenario=` toggle) and the cross-cutting concerns below. The generated client
* is the only place HTTP shapes are known; this is the only place it meets
* Angular's HTTP stack — i.e. the one seam to add:
* - timeout (done — REQUEST_TIMEOUT_MS),
* - correlation id (done — X-Correlation-Id, echoed in backend logs),
* - idempotency key for writes (done — Idempotency-Key, stable per logical
* submit via `withIdempotencyKey`/`runSubmit`, so a retry dedupes),
* - auth: attach `Authorization: Bearer …` here (one line) when real DigiD lands,
* - retry/backoff (done — GET only, `retry({ count: 2, delay: 500 })`; writes are
* never auto-retried, which is exactly what makes the idempotency key above
* matter only for a future/manual retry, not routine traffic).
*/
export function httpClientFetch(http: HttpClient) {
return {
async fetch(url: RequestInfo, init?: RequestInit): Promise<Response> {
const method = (init?.method ?? 'GET').toUpperCase();
const headers: Record<string, string> = {
...((init?.headers ?? {}) as Record<string, string>),
'X-Correlation-Id': crypto.randomUUID(),
};
if (method !== 'GET') headers['Idempotency-Key'] = currentIdempotencyKey();
try {
const request$ = http
.request(method, url as string, {
body: init?.body as string | undefined,
headers,
observe: 'response',
responseType: 'text',
})
.pipe(timeout(REQUEST_TIMEOUT_MS));
const res = await firstValueFrom(
method === 'GET' ? request$.pipe(retry({ count: 2, delay: 500 })) : request$,
);
// 204/205/304 are null-body statuses — new Response(body, …) throws for any non-null body.
const nullBody = res.status === 204 || res.status === 205 || res.status === 304;
return new Response(nullBody ? null : (res.body ?? ''), { status: res.status || 200 });
} catch (e) {
if (e instanceof TimeoutError) return new Response('', { status: 504 });
const err = e as HttpErrorResponse;
const body = typeof err.error === 'string' ? err.error : JSON.stringify(err.error ?? {});
// ponytail: clamp to a Response-constructible status (an aborted/interceptor
// request reports status 0, which `new Response` rejects).
const status = err.status >= 200 && err.status <= 599 ? err.status : 500;
return new Response(body, { status });
}
},
};
}
/** Provide a root ApiClient that talks through HttpClient. Base URL comes from the
* environment (relative '' in dev → proxy; configurable per deployment). */
export function provideApiClient(): Provider {
return {
provide: ApiClient,
useFactory: (http: HttpClient) => new ApiClient(environment.apiBaseUrl, httpClientFetch(http)),
deps: [HttpClient],
};
}
export type { ProblemDetails };
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { problemDetail, problemFieldErrors } from './api-error';
describe('problemDetail', () => {
it('extracts the detail from an RFC-7807 ProblemDetails', () => {
expect(problemDetail({ detail: 'Afgewezen: 0 uren.', status: 422 }, 'fallback')).toBe(
'Afgewezen: 0 uren.',
);
});
it('falls back when there is no detail', () => {
expect(problemDetail(new Error('boom'), 'fallback')).toBe('fallback');
expect(problemDetail({ status: 500 }, 'fallback')).toBe('fallback');
expect(problemDetail(undefined, 'fallback')).toBe('fallback');
});
});
describe('problemFieldErrors (G4 seam)', () => {
it('maps a ValidationProblemDetails errors dict to first-message-per-field', () => {
expect(
problemFieldErrors({ errors: { straat: ['Verplicht.'], postcode: ['Ongeldig.', 'x'] } }),
).toEqual({ straat: 'Verplicht.', postcode: 'Ongeldig.' });
});
it('returns {} when there is no errors envelope (the current backend shape)', () => {
expect(problemFieldErrors({ detail: 'one banner' })).toEqual({});
expect(problemFieldErrors(new Error('boom'))).toEqual({});
expect(problemFieldErrors(undefined)).toEqual({});
});
});
@@ -0,0 +1,36 @@
import { ProblemDetails } from './api-client';
/**
* Extract a human-readable message from a rejected API call. A 4xx/5xx with a
* ProblemDetails body (RFC 7807) is thrown by the generated client as the parsed
* object; anything else falls back to the given message.
*/
export function problemDetail(e: unknown, fallback: string): string {
if (e && typeof e === 'object' && 'detail' in e) {
const detail = (e as ProblemDetails).detail;
if (typeof detail === 'string' && detail) return detail;
}
return fallback;
}
/**
* SEAM (G4): map a server validation envelope to field-level errors.
*
* ASP.NET's ValidationProblemDetails carries `errors: { field: string[] }`. The
* backend today returns only `detail` (one banner message), so this returns `{}`.
* When the backend starts sending `errors`, a machine's `SubmitFailed` handler can
* merge this into its own `errors` map the field-keyed shape the wizards already
* render so a rejection shows inline per field, not just as a banner. The
* consumer hook is the only thing left to wire; the contract boundary lives here.
*/
export function problemFieldErrors(e: unknown): Record<string, string> {
if (!e || typeof e !== 'object' || !('errors' in e)) return {};
const errors = (e as { errors?: unknown }).errors;
if (!errors || typeof errors !== 'object') return {};
const out: Record<string, string> = {};
for (const [field, msgs] of Object.entries(errors as Record<string, unknown>)) {
const first = Array.isArray(msgs) ? msgs[0] : msgs;
if (typeof first === 'string') out[field] = first;
}
return out;
}
@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import { stripDevParams } from './dev-params';
describe('stripDevParams (WP-37)', () => {
it('removes ?scenario and ?role so the stored dev value wins on reload', () => {
expect(stripDevParams('http://localhost:4200/dashboard?scenario=slow&role=admin')).toBe(
'http://localhost:4200/dashboard',
);
});
it('keeps unrelated query params and the path/hash', () => {
expect(stripDevParams('http://localhost:4200/beheer/zaken?scenario=error&tab=2#top')).toBe(
'http://localhost:4200/beheer/zaken?tab=2#top',
);
});
it('is a no-op when neither param is present', () => {
expect(stripDevParams('http://localhost:4200/dashboard')).toBe(
'http://localhost:4200/dashboard',
);
});
});
@@ -0,0 +1,14 @@
/**
* Remove the dev-only `?scenario=` and `?role=` params from a URL (WP-37). Once the
* dev switcher (debug-state) has been used, sessionStorage is the authoritative source
* for both `currentScenario()`/`currentRole()` read the URL FIRST, so a stale param
* left in the address bar would override the switcher on reload (the "stuck on slow"
* bug). Stripping the params before reload lets the stored value win. Pure: returns the
* rewritten href, mutates nothing.
*/
export function stripDevParams(href: string): string {
const url = new URL(href);
url.searchParams.delete('scenario');
url.searchParams.delete('role');
return url.toString();
}
@@ -0,0 +1,38 @@
import { Injectable, inject } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { ApiClient } from '@shared/infrastructure/api-client';
import { FeatureFlag } from '@shared/domain/feature-flag';
/**
* Infrastructure adapter for feature flags (WP-47): `GET /flags` (resolved set, drives FE gating)
* and the admin `PUT /admin/flags/{key}`. The single place the ApiClient lives for flags; the
* store parses at the boundary.
*/
@Injectable({ providedIn: 'root' })
export class FeatureFlagsAdapter {
private client = inject(ApiClient);
list() {
return this.client.flagsAll();
}
set(key: string, enabled: boolean) {
return this.client.flags(key, { enabled });
}
}
/** Trust-boundary parse of the flag set. */
export function parseFlags(json: unknown): Result<string, FeatureFlag[]> {
if (!Array.isArray(json)) return err('flags: not an array');
const out: FeatureFlag[] = [];
for (const f of json) {
if (typeof f !== 'object' || f === null) return err('flags: row not an object');
const d = f as Partial<FeatureFlag>;
if (typeof d.key !== 'string' || typeof d.enabled !== 'boolean') return err('flags: bad shape');
out.push({
key: d.key,
description: typeof d.description === 'string' ? d.description : '',
enabled: d.enabled,
});
}
return ok(out);
}
@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest';
import { parseMe } from './me.adapter';
describe('parseMe (trust boundary)', () => {
it('parses a known capability list', () => {
const r = parseMe({ capabilities: ['brief:approve', 'brief:reject', 'brief:send'] });
expect(r).toEqual({ ok: true, value: ['brief:approve', 'brief:reject', 'brief:send'] });
});
it('parses an empty list (drafter — no capabilities)', () => {
expect(parseMe({ capabilities: [] })).toEqual({ ok: true, value: [] });
});
it('recognizes the admin org-template capability (WP-23)', () => {
expect(parseMe({ capabilities: ['orgtemplate:edit'] })).toEqual({
ok: true,
value: ['orgtemplate:edit'],
});
});
it('drops unrecognized capability strings instead of rejecting the response', () => {
const r = parseMe({ capabilities: ['brief:approve', 'unknown:future-thing'] });
expect(r).toEqual({ ok: true, value: ['brief:approve'] });
});
it('rejects malformed responses instead of trusting them', () => {
expect(parseMe(null).ok).toBe(false);
expect(parseMe({}).ok).toBe(false);
expect(parseMe({ capabilities: 'brief:approve' }).ok).toBe(false);
});
});
@@ -0,0 +1,41 @@
import { Injectable, inject, resource } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { Capability } from '@shared/domain/capability';
import { ApiClient } from '@shared/infrastructure/api-client';
const KNOWN: readonly Capability[] = [
'brief:approve',
'brief:reject',
'brief:send',
'orgtemplate:edit',
'stamdata:edit',
'cases:manage',
'flags:manage',
];
/**
* Infrastructure adapter for `GET /me` (PRD-0002 §6): the current principal's
* coarse, role-derived capabilities nav/menu-level, not tied to any one screen's
* live status (contrast a screen's own decision DTO, e.g. `BriefViewDto.decisions`).
*/
@Injectable({ providedIn: 'root' })
export class MeAdapter {
private client = inject(ApiClient);
meResource() {
return resource({ loader: () => this.client.me() });
}
}
/**
* Trust-boundary parse. An unrecognized capability string is dropped rather than
* rejecting the whole response deny-by-default already covers it (AccessStore.can
* returns false for anything not in the set), and it lets the backend grow the
* capability list without breaking an older FE build.
*/
export function parseMe(json: unknown): Result<string, Capability[]> {
if (typeof json !== 'object' || json === null) return err('me: not an object');
const dto = json as { capabilities?: unknown };
if (!Array.isArray(dto.capabilities)) return err('me: missing/invalid capabilities');
return ok(dto.capabilities.filter((c): c is Capability => KNOWN.includes(c as Capability)));
}
@@ -0,0 +1,54 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { roleInterceptor } from './role.interceptor';
// currentRole() reads window.location.search; set it via the real URL rather than
// vi.mock (the Angular unit-test system forbids mocking relative imports).
beforeEach(() => window.history.replaceState({}, '', '/?role=admin'));
afterEach(() => {
window.history.replaceState({}, '', '/');
sessionStorage.clear(); // currentRole() now persists the dev role; don't leak across tests
});
// Minimal stand-in for HttpRequest — the interceptor only reads `url` and calls
// `clone({ setHeaders })`. Avoids importing @angular/common/http (its XHR chunk needs
// the JIT compiler under vitest).
function fakeReq(url: string) {
const make = (headers: Map<string, string>) => ({
url,
headers,
clone(opts: { setHeaders: Record<string, string> }) {
const next = new Map(headers);
for (const [k, v] of Object.entries(opts.setHeaders)) next.set(k, v);
return make(next);
},
});
return make(new Map());
}
/** Run the interceptor and return the request it forwarded to `next`. */
function forward(url: string) {
let seen!: ReturnType<typeof fakeReq>;
const next = (r: ReturnType<typeof fakeReq>) => {
seen = r;
return undefined;
};
// Cast: the fake matches the shape the interceptor actually touches.
(roleInterceptor as unknown as (req: unknown, next: unknown) => unknown)(fakeReq(url), next);
return seen;
}
describe('roleInterceptor', () => {
it.each([
'/api/v1/brief',
'/api/v1/admin/org-template',
'/api/v1/stamdata', // WP-29: the admin stamdata reads 403 without X-Role
'/api/v1/stamdata/professions?peildatum=1999-01-01',
'/api/v1/me',
])('stamps X-Role on the role-aware endpoint %s', (url) => {
expect(forward(url).headers.get('X-Role')).toBe('admin');
});
it('leaves an unrelated endpoint untouched', () => {
expect(forward('/api/v1/duo/diplomas').headers.has('X-Role')).toBe(false);
});
});
@@ -0,0 +1,25 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { currentRole } from './role';
/**
* Dev-only: stamps role-aware requests with the current `?role=` as an `X-Role`
* header so the backend can enforce the drafter/approver/admin rules. Only the
* brief, org-template, stamdata and /me endpoints carry it (WP-23 widened the set
* /me must see the role or `AccessStore` could never learn a capability; WP-29 added
* /stamdata, whose admin-only reads 403 without it); everything else is untouched.
* A new admin-gated endpoint MUST be added here or its page silently 403s.
*/
const ROLE_AWARE = [
'/api/v1/brief',
'/api/v1/admin/org-template',
'/api/v1/admin/cases',
'/api/v1/admin/audit',
'/api/v1/admin/flags',
'/api/v1/stamdata',
'/api/v1/me',
];
export const roleInterceptor: HttpInterceptorFn = (req, next) => {
if (!ROLE_AWARE.some((prefix) => req.url.includes(prefix))) return next(req);
return next(req.clone({ setHeaders: { 'X-Role': currentRole() } }));
};
+36
View File
@@ -0,0 +1,36 @@
import { Role } from '@shared/domain/role';
/**
* Dev-only role stand-in (the reading MECHANISM; the `Role` type is domain). This
* POC has one faked self-service user and no real identities, so the two-person
* letter workflow (drafter vs approver) plus admin is driven by a `?role=` query
* param. The backend receives it as an `X-Role` header (see role.interceptor),
* resolves it into a `Principal` server-side, and is the sole authority on what that
* principal may do (PRD-0002 phase P1, `Authz.Can`) the FE only renders the
* resulting decision flags, it no longer derives permission from this value itself.
*
* **Sticky within the tab (sessionStorage):** the interceptor reads this per request,
* but navigation drops the query param (login redirects to /dashboard, RouterLinks
* don't carry it), which would silently revert an admin to drafter mid-session and
* 403 the admin endpoints. So a `?role=` seen in the URL is remembered for the tab;
* later requests use the remembered value. Set `?role=drafter` (or a fresh tab) to
* reset. Dev-only the interceptor itself is only wired under `isDevMode()`.
*/
const STORAGE_KEY = 'dev-role';
export const ROLES: readonly Role[] = ['drafter', 'approver', 'admin'];
const isRole = (v: string | null): v is Role => !!v && ROLES.includes(v as Role);
export function currentRole(): Role {
const fromUrl = new URLSearchParams(window.location.search).get('role');
if (isRole(fromUrl)) {
sessionStorage.setItem(STORAGE_KEY, fromUrl);
return fromUrl;
}
const stored = sessionStorage.getItem(STORAGE_KEY);
return isRole(stored) ? stored : 'drafter';
}
/** Dev switcher entry point: persist the chosen role for the tab (WP-33). */
export function setRole(r: Role): void {
sessionStorage.setItem(STORAGE_KEY, r);
}
@@ -0,0 +1,33 @@
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 API data requests based on
* ?scenario= so loading / empty / error states can be shown on demand.
* Non-API requests are untouched.
*/
export const scenarioInterceptor: HttpInterceptorFn = (req, next) => {
if (!req.url.includes('/api/')) 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':
// '[]' so the typed client parses it to an empty array (notes → Empty state).
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);
}
};
@@ -0,0 +1,29 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { currentScenario, setScenario } from './scenario';
const setUrl = (search: string) => history.pushState({}, '', search || '/');
describe('scenario (dev mechanism)', () => {
beforeEach(() => {
sessionStorage.clear();
setUrl('/');
});
it('reads a valid ?scenario= from the URL and persists it for the tab', () => {
setUrl('?scenario=error');
expect(currentScenario()).toBe('error');
setUrl('/'); // navigation drops the query param — value stays sticky
expect(currentScenario()).toBe('error');
});
it('falls back to default when nothing is set or the value is invalid', () => {
expect(currentScenario()).toBe('default');
setUrl('?scenario=nonsense');
expect(currentScenario()).toBe('default');
});
it('setScenario persists the chosen scenario', () => {
setScenario('slow');
expect(currentScenario()).toBe('slow');
});
});
@@ -0,0 +1,45 @@
export type Scenario =
| 'default'
| 'slow'
| 'loading'
| 'empty'
| 'error'
// upload-only (the multipart POST is hand-written XHR, so it bypasses the HTTP
// interceptor — these are simulated in upload.adapter.ts instead):
| 'upload-slow'
| 'upload-fail';
export const SCENARIOS: readonly Scenario[] = [
'default',
'slow',
'loading',
'empty',
'error',
'upload-slow',
'upload-fail',
];
const STORAGE_KEY = 'dev-scenario';
const isScenario = (v: string | null): v is Scenario => !!v && SCENARIOS.includes(v as Scenario);
/**
* Reads the active demo scenario so a demo can force each async state.
* Sticky within the tab (sessionStorage), mirroring `role.ts`: a `?scenario=` in the
* URL sets it; later navigation (which drops the query param) keeps the remembered
* value. Set `?scenario=default`, use the dev switcher, or open a fresh tab to reset.
* Dev-only the interceptor that consumes this is wired only under `isDevMode()`.
*/
export function currentScenario(): Scenario {
const fromUrl = new URLSearchParams(window.location.search).get('scenario');
if (isScenario(fromUrl)) {
sessionStorage.setItem(STORAGE_KEY, fromUrl);
return fromUrl;
}
const stored = sessionStorage.getItem(STORAGE_KEY);
return isScenario(stored) ? stored : 'default';
}
/** Dev switcher entry point: persist the chosen scenario for the tab (WP-33). */
export function setScenario(s: Scenario): void {
sessionStorage.setItem(STORAGE_KEY, s);
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest';
import { parseBsn } from './bsn';
describe('parseBsn (elfproef)', () => {
it('accepts a valid BSN (passes the elfproef)', () => {
const r = parseBsn('123456782'); // Σ d·w = 154, divisible by 11
expect(r.ok && r.value).toBe('123456782');
});
it('rejects a 9-digit number that fails the elfproef', () => {
expect(parseBsn('123456789').ok).toBe(false); // sum 147, not divisible
});
it('rejects wrong length / non-digits / all zeros', () => {
expect(parseBsn('12345').ok).toBe(false);
expect(parseBsn('abcdefghi').ok).toBe(false);
expect(parseBsn('000000000').ok).toBe(false);
});
});
+28
View File
@@ -0,0 +1,28 @@
import { Brand, Result, ok, err } from '@shared/kernel/fp';
/**
* Value object: a Dutch **BSN** (burgerservicenummer) art. 9 GDPR/AVG special-category
* data. "Parse, don't validate": a `Bsn` is a distinct type from a raw string, mintable only
* via `parseBsn`, so holding one is proof it passed the **elfproef** (11-test) checksum, not
* just a 9-digit shape. Format/checksum only identity is still faked in this POC (DigiD stub).
*/
export type Bsn = Brand<string, 'Bsn'>;
// Positional weights for the elfproef: 9·d1 + 8·d2 + … + 2·d8 1·d9 ≡ 0 (mod 11).
const WEIGHTS = [9, 8, 7, 6, 5, 4, 3, 2, -1];
// #region showcase:parseBsn
export function parseBsn(raw: string): Result<string, Bsn> {
const t = raw.trim();
if (!/^\d{9}$/.test(t)) {
return err($localize`:@@validation.bsn:Voer een geldig BSN van 9 cijfers in.`);
}
const sum = [...t].reduce((acc, ch, i) => acc + Number(ch) * WEIGHTS[i], 0);
if (t === '000000000' || sum % 11 !== 0) {
return err(
$localize`:@@validation.bsnElfproef:Dit is geen geldig BSN (klopt niet met de elfproef).`,
);
}
return ok(t as Bsn); // holding a Bsn is proof it passed the elfproef
}
// #endregion showcase:parseBsn
+22
View File
@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import { formatDatumNl } from './datum';
describe('formatDatumNl', () => {
it('formats a Date in long Dutch form', () => {
expect(formatDatumNl(new Date(2026, 6, 2))).toBe('2 juli 2026');
});
it('formats an ISO string the same way', () => {
expect(formatDatumNl('2026-07-02')).toBe('2 juli 2026');
});
it('is empty-safe: undefined, null, and empty string all yield the empty string', () => {
expect(formatDatumNl(undefined)).toBe('');
expect(formatDatumNl(null)).toBe('');
expect(formatDatumNl('')).toBe('');
});
it('returns empty for an unparseable string rather than "Invalid Date"', () => {
expect(formatDatumNl('not-a-date')).toBe('');
});
});
+16
View File
@@ -0,0 +1,16 @@
/**
* The one hand-written date formatter for pure TS (non-template) code a domain
* rule or a `$localize` string can't reach for Angular's `DatePipe`, so this covers
* that gap. Templates use `DatePipe` (`| date: 'longDate'`) instead; don't add a
* second hand-rolled formatter for either case.
*/
export function formatDatumNl(d: Date | string | undefined | null): string {
if (!d) return '';
const date = typeof d === 'string' ? new Date(d) : d;
if (Number.isNaN(date.getTime())) return '';
return new Intl.DateTimeFormat('nl-NL', {
day: 'numeric',
month: 'long',
year: 'numeric',
}).format(date);
}
+32
View File
@@ -0,0 +1,32 @@
/**
* 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 };
/** Narrow a tagged union to one variant by its `tag`, or null. The single place
the cast lives TS can't narrow through a runtime tag argument, so callers get
`whenTag(state, 'Editing')?.foo` instead of repeating `as Extract<…>`. */
export function whenTag<U extends { tag: string }, K extends U['tag']>(
u: U,
tag: K,
): Extract<U, { tag: K }> | null {
return u.tag === tag ? (u as Extract<U, { tag: K }>) : null;
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest';
import { maskBsn, maskTail } from './pii';
describe('pii maskers', () => {
it('maskBsn keeps the last 3 digits', () => {
expect(maskBsn('123456789')).toBe('******789');
});
it('maskTail keeps the requested tail length', () => {
expect(maskTail('abcdef', 2)).toBe('****ef');
});
it('masks the whole value when it is not longer than the kept tail', () => {
expect(maskBsn('12')).toBe('**');
expect(maskBsn('')).toBe('');
});
});
+19
View File
@@ -0,0 +1,19 @@
/**
* PII masking pure functional core (WP-40). Data-minimisation helpers shared by the app
* (dev state panel, the masked-value atom, anywhere sensitive data is shown). No framework,
* no domain imports. The backend keeps a `MaskTail` twin in sync (see Program.cs).
*/
export const REDACTED = 'redacted';
// #region showcase:mask
/** Keep the last `keep` characters, mask the rest with `*`. */
export function maskTail(value: string, keep: number): string {
if (value.length <= keep) return '*'.repeat(value.length);
return '*'.repeat(value.length - keep) + value.slice(-keep);
}
/** Mask a BSN / BIG-nummer for display: keep the last 3 digits, mask the rest. */
export function maskBsn(value: string): string {
return maskTail(value, 3);
}
// #endregion showcase:mask
+59
View File
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest';
import {
RichTextBlock,
deepCopyBlock,
emptyBlock,
isBlockEmpty,
placeholderKeysIn,
} from './rich-text';
const block = (): RichTextBlock => ({
paragraphs: [
{
nodes: [
{ type: 'text', text: 'Beste ' },
{ type: 'placeholder', key: 'naam' },
],
},
{
nodes: [
{ type: 'placeholder', key: 'datum' },
{ type: 'placeholder', key: 'naam' },
],
},
],
});
describe('rich-text', () => {
it('emptyBlock is one empty paragraph and reads as empty', () => {
expect(emptyBlock()).toEqual({ paragraphs: [{ nodes: [] }] });
expect(isBlockEmpty(emptyBlock())).toBe(true);
});
it('isBlockEmpty is false when any placeholder or non-blank text exists', () => {
expect(isBlockEmpty({ paragraphs: [{ nodes: [{ type: 'text', text: ' ' }] }] })).toBe(true);
expect(isBlockEmpty({ paragraphs: [{ nodes: [{ type: 'placeholder', key: 'x' }] }] })).toBe(
false,
);
expect(isBlockEmpty({ paragraphs: [{ nodes: [{ type: 'text', text: 'hoi' }] }] })).toBe(false);
});
it('placeholderKeysIn walks in document order, keeping duplicates', () => {
expect(placeholderKeysIn(block())).toEqual(['naam', 'datum', 'naam']);
});
it('deepCopyBlock is an independent value copy (frozen snapshot)', () => {
const original = block();
const copy = deepCopyBlock(original);
expect(copy).toEqual(original);
expect(copy).not.toBe(original);
expect(copy.paragraphs[0]).not.toBe(original.paragraphs[0]);
// Mutating the copy must not touch the original — proves no shared reference.
(copy.paragraphs[0].nodes as { type: 'text'; text: string }[])[0] = {
type: 'text',
text: 'CHANGED',
};
expect(placeholderKeysIn(original)).toEqual(['naam', 'datum', 'naam']);
expect((original.paragraphs[0].nodes[0] as { text: string }).text).toBe('Beste ');
});
});
+73
View File
@@ -0,0 +1,73 @@
/**
* Rich text as a *serialisable value*, not opaque HTML.
*
* A block is a node tree. Because a placeholder is a first-class NODE (not a
* `{{token}}` substring hidden inside a string), highlighting it, inserting it,
* and linting it are all pure functions over data no regex over markup. This
* is the whole reason the letter feature stays in the "impossible states" style:
* the value the app holds is always well-shaped, and the imperative editor is
* quarantined behind one component that converts to/from this tree.
*/
export type Mark = 'bold' | 'italic' | 'underline';
export type RichTextNode =
| { readonly type: 'text'; readonly text: string; readonly marks?: readonly Mark[] }
| { readonly type: 'placeholder'; readonly key: string } // resolved to a value at send
| { readonly type: 'lineBreak' };
export interface Paragraph {
readonly nodes: readonly RichTextNode[];
// A line can be a plain paragraph (undefined) or an item in a bullet/numbered list.
// Consecutive lines with the same list kind render as one <ul>/<ol>.
readonly list?: 'bullet' | 'number';
}
export interface RichTextBlock {
readonly paragraphs: readonly Paragraph[];
}
/** An empty editable block is one empty paragraph never zero paragraphs, so the
editor always has a caret line. */
export function emptyBlock(): RichTextBlock {
return { paragraphs: [{ nodes: [] }] };
}
/** True when the block carries no visible content (used for "required section empty"). */
export function isBlockEmpty(block: RichTextBlock): boolean {
return block.paragraphs.every((p) =>
p.nodes.every((n) => (n.type === 'text' ? n.text.trim() === '' : false)),
);
}
/** The frozen-snapshot primitive: a deep VALUE copy of a block. Inserting a library
passage into a letter copies its tree through here, so the letter never shares a
reference with the library later library edits can't mutate an existing letter. */
export function deepCopyBlock(block: RichTextBlock): RichTextBlock {
// ponytail: structuredClone is exactly a deep value copy of a JSON-shaped tree;
// a hand-rolled walk would be more code for the same result.
return structuredClone(block) as RichTextBlock;
}
/** All visible text of a block as one lowercased string for client-side search over
passages. Placeholders contribute their key so "naam" matches a `naam_zorgverlener` chip. */
export function textOf(block: RichTextBlock): string {
return block.paragraphs
.flatMap((p) =>
p.nodes.map((n) => (n.type === 'text' ? n.text : n.type === 'placeholder' ? n.key : '')),
)
.join(' ')
.toLowerCase();
}
/** Every placeholder key used in a block, in document order (duplicates kept the
caller dedupes when it wants a set). */
export function placeholderKeysIn(block: RichTextBlock): string[] {
const keys: string[] = [];
for (const p of block.paragraphs) {
for (const n of p.nodes) {
if (n.type === 'placeholder') keys.push(n.key);
}
}
return keys;
}
@@ -0,0 +1,38 @@
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: $localize`:@@crumb.dashboard:Mijn overzicht` },
'/registratie': { label: $localize`:@@crumb.registratie:Mijn gegevens`, parent: '/dashboard' },
'/registreren': { label: $localize`:@@crumb.registreren:Inschrijven`, parent: '/dashboard' },
'/herregistratie': {
label: $localize`:@@crumb.herregistratie:Herregistratie`,
parent: '/dashboard',
},
'/intake': { label: $localize`:@@crumb.intake:Herregistratie-intake`, parent: '/dashboard' },
'/concepts': { label: $localize`:@@crumb.concepts: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;
}
@@ -0,0 +1,41 @@
import { Component, input } from '@angular/core';
import { RouterLink } from '@angular/router';
export interface BreadcrumbItem {
label: string;
link?: string; // omit on the current (last) page
}
/** Chrome: breadcrumb navigation, styled for the CIBG titlebar (`.titlebar .title`)
plain links with a chevron `::after` from the CIBG Icons font, current page as an
unlinked, bold span. Domain-free the caller supplies the trail. */
@Component({
selector: 'app-breadcrumb',
imports: [RouterLink],
// CIBG's global "header nav" background rule matches ANY nav inside a <header>
// — including this one, wherever it's mounted. Override it so the breadcrumb
// never carries its own background (it should show whatever's behind it, e.g.
// the titlebar's robijn fill).
styles: [
`
nav {
background: none;
}
`,
],
template: `
<nav i18n-aria-label="@@breadcrumb.aria" aria-label="Kruimelpad">
<span class="visually-hidden" i18n="@@breadcrumb.hier">U bevindt zich hier:</span>
@for (item of items(); track item.label; let last = $last) {
@if (item.link && !last) {
<a [routerLink]="item.link">{{ item.label }}</a>
} @else {
<span aria-current="page">{{ item.label }}</span>
}
}
</nav>
`,
})
export class BreadcrumbComponent {
items = input.required<BreadcrumbItem[]>();
}
@@ -0,0 +1,35 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideRouter } from '@angular/router';
import { BreadcrumbComponent } from './breadcrumb.component';
const meta: Meta<BreadcrumbComponent> = {
title: 'Design System/Molecules/Breadcrumb',
component: BreadcrumbComponent,
decorators: [applicationConfig({ providers: [provideRouter([])] })],
render: (args) => ({
props: args,
// Rendered inside a mock .titlebar .title so the story reflects the real chrome.
template: `<div class="titlebar" style="padding: 1rem"><div class="title"><app-breadcrumb [items]="items" /></div></div>`,
}),
};
export default meta;
type Story = StoryObj<BreadcrumbComponent>;
export const TweeNiveaus: Story = {
args: {
items: [
{ label: 'Mijn omgeving', link: '/dashboard' },
{ label: 'Inschrijven in het BIG-register' },
],
},
};
export const DrieNiveaus: Story = {
args: {
items: [
{ label: 'Mijn omgeving', link: '/dashboard' },
{ label: 'Registratie', link: '/registratie' },
{ label: 'Inschrijven' },
],
},
};
@@ -0,0 +1,97 @@
import { Component, computed, inject, input } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router } from '@angular/router';
import { EMPTY, filter } from 'rxjs';
import { Locale, localeLinks } from './locale-links';
// CIBG-GAP EXTENSION: "Taal instellen" (designsystem.cibg.nl/componenten/taal-instellen) — no
// vendored Huisstijl class ships for it, so this is a small hand-rolled surface built from the
// token bridge. See cibg-gaps.mdx.
/**
* Organism: CIBG "Taal instellen" language switcher. A `<nav>` region (screenreader heading +
* aria-label) with one link per locale the endonym, tagged with its `lang`/`hreflang`, the
* active one marked `aria-current` and rendered as text (not a link).
*
* Compile-time $localize means each locale is a separate bundle under `/<locale>/`, so switching
* is a plain full-page navigation to the sibling bundle (not a runtime toggle). The active locale
* is read from the baked `<base href>` (`/en/` en, else nl) the deployment truth, independent
* of the app-config `LOCALE_ID`. Only functional where both locale bundles are served (the
* localized build, e.g. `npm run serve:i18n`), not under plain `ng serve` (nl-only at `/`).
*
* The shell (and this switcher within it) is a persistent parent only the routed child
* swaps so `location.pathname` must be re-read on every completed navigation (same
* `toSignal(router.events...)` idiom as `site-header.component.ts`'s breadcrumb `url`), or the
* target link freezes at whichever route was active when the switcher was first constructed.
*/
@Component({
selector: 'app-language-switcher',
styles: [
`
nav {
display: flex;
justify-content: flex-end;
gap: var(--rhc-space-max-md);
padding: var(--rhc-space-max-sm) var(--rhc-space-max-2xl);
font-size: var(--rhc-text-font-size-sm);
}
a {
color: var(--rhc-color-hemelblauw-700);
}
[aria-current] {
font-weight: var(--rhc-text-font-weight-semi-bold);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
`,
],
template: `
<nav [attr.aria-label]="navLabel">
<h2 class="sr-only">{{ heading }}</h2>
@for (l of links(); track l.locale) {
@if (l.active) {
<span [attr.lang]="l.locale" aria-current="true">{{ l.label }}</span>
} @else {
<a [attr.lang]="l.locale" [attr.hreflang]="l.locale" [href]="l.href">{{ l.label }}</a>
}
}
</nav>
`,
})
export class LanguageSwitcherComponent {
/** Override the detected locale (stories/tests); the app detects it from the base href. */
activeLocale = input<Locale | undefined>(undefined);
private readonly detected: Locale = /\/en\//.test(document.baseURI) ? 'en' : 'nl';
private readonly loc =
typeof location !== 'undefined'
? location
: ({ pathname: '/', search: '', hash: '' } as Location);
private router = inject(Router, { optional: true });
private nav = toSignal(
this.router?.events.pipe(filter((e) => e instanceof NavigationEnd)) ?? EMPTY,
{ initialValue: null },
);
protected links = computed(() => {
this.nav(); // recompute on every completed navigation — loc.pathname is read fresh below
return localeLinks(
this.loc.pathname,
this.activeLocale() ?? this.detected,
this.loc.search,
this.loc.hash,
);
});
protected navLabel = $localize`:@@lang.navLabel:Taal / Language`;
protected heading = $localize`:@@lang.heading:Kies een taal`;
}
@@ -0,0 +1,15 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { LanguageSwitcherComponent } from './language-switcher.component';
const meta: Meta<LanguageSwitcherComponent> = {
title: 'Design System/Organisms/Language Switcher',
component: LanguageSwitcherComponent,
};
export default meta;
type Story = StoryObj<LanguageSwitcherComponent>;
/** Dutch active (the source locale). */
export const NederlandsActive: Story = { args: { activeLocale: 'nl' } };
/** English active. */
export const EnglishActive: Story = { args: { activeLocale: 'en' } };
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { localeLinks } from './locale-links';
describe('localeLinks (nl at root, en under /en/)', () => {
it('an nl route (no prefix) links nl to the bare path, en under /en, marks active', () => {
const links = localeLinks('/dashboard', 'nl');
expect(links.map((l) => [l.locale, l.href, l.active])).toEqual([
['nl', '/dashboard', true],
['en', '/en/dashboard', false],
]);
});
it('an en route strips the /en prefix for the nl target (deep path, en active)', () => {
const links = localeLinks('/en/beheer/audit', 'en');
expect(links.find((l) => l.locale === 'nl')!.href).toBe('/beheer/audit');
expect(links.find((l) => l.locale === 'en')!.active).toBe(true);
});
it('keeps query + hash on both targets', () => {
const links = localeLinks('/registreren', 'nl', '?scenario=slow', '#top');
expect(links.find((l) => l.locale === 'nl')!.href).toBe('/registreren?scenario=slow#top');
expect(links.find((l) => l.locale === 'en')!.href).toBe('/en/registreren?scenario=slow#top');
});
it('the root maps nl → / and en → /en/', () => {
const links = localeLinks('/', 'nl');
expect(links.find((l) => l.locale === 'nl')!.href).toBe('/');
expect(links.find((l) => l.locale === 'en')!.href).toBe('/en/');
});
});
@@ -0,0 +1,39 @@
/** The app's two locales (Angular $localize: source `nl` + translation `en`). */
export type Locale = 'nl' | 'en';
export interface LocaleLink {
readonly locale: Locale;
/** Endonym — each language named in its own language (CIBG "Taal instellen"), not a code. */
readonly label: string;
/** Absolute path into the other locale's bundle, preserving the current route. */
readonly href: string;
readonly active: boolean;
}
const LOCALES: readonly { locale: Locale; label: string }[] = [
{ locale: 'nl', label: 'Nederlands' },
{ locale: 'en', label: 'English' },
];
/**
* Build the two language links for the switcher. Compile-time i18n serves the source locale
* (nl) at the ROOT (`subPath: ''`) and en under `/en/`, so switching is a full navigation to the
* sibling bundle at the same route. Strips a leading `/en` from the current path, then targets nl
* at the bare path and en under `/en`. Keeps query + hash. Pure no DOM (the component passes
* `location.*` in).
*/
export function localeLinks(
pathname: string,
active: Locale,
search = '',
hash = '',
): LocaleLink[] {
const rest = pathname.replace(/^\/en(?=\/|$)/, '') || '/';
const href = (locale: Locale) => `${locale === 'en' ? `/en${rest}` : rest}${search}${hash}`;
return LOCALES.map(({ locale, label }) => ({
locale,
label,
href: href(locale),
active: locale === active,
}));
}
@@ -0,0 +1,51 @@
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. The breadcrumb lives in the site header (blue bar), so
it's not repeated here. Rendered inside the persistent ShellComponent via the
router outlet, so it owns only the content (not chrome). */
@Component({
selector: 'app-page-shell',
imports: [HeadingComponent, LinkComponent],
styles: [
`
:host {
display: block;
}
.body--narrow {
max-inline-size: var(--app-form-narrow);
}
.back {
margin: 0 0 var(--rhc-space-max-lg);
}
.intro {
margin-block: var(--rhc-space-max-md) var(--rhc-space-max-2xl);
max-inline-size: 42rem;
color: var(--rhc-color-foreground-subtle);
}
`,
],
template: `
<div [class.body--narrow]="width() === 'narrow'">
@if (backLink()) {
<p class="back">
<app-link [to]="backLink()!"> {{ backLabel() }}</app-link>
</p>
}
<app-heading [level]="1">{{ heading() }}</app-heading>
@if (intro()) {
<p class="intro">{{ intro() }}</p>
}
<ng-content />
</div>
`,
})
export class PageShellComponent {
heading = input.required<string>();
intro = input<string>();
backLink = input<string>();
backLabel = input($localize`:@@pageShell.backLabel:Terug naar overzicht`);
width = input<'default' | 'narrow'>('default');
}
@@ -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: 'Design System/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.' },
};
+45
View File
@@ -0,0 +1,45 @@
import {
DOCUMENT,
ENVIRONMENT_INITIALIZER,
EnvironmentInjector,
afterNextRender,
inject,
} from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
/** Template-layer wiring (not a component): on every route change after the
initial load, moves focus to the new page's `<h1>` (page-shell always
renders one) so screen-reader/keyboard users land on the new content
instead of wherever focus happened to be. Falls back to `#main` (the
shell's landmark) if a page has no heading. Deferred via `afterNextRender`
so it doesn't race Angular's view-transition DOM swap. */
export function provideRouteFocus() {
return {
provide: ENVIRONMENT_INITIALIZER,
multi: true,
useValue: () => {
const router = inject(Router);
const document = inject(DOCUMENT);
const injector = inject(EnvironmentInjector);
let isInitialLoad = true;
router.events.subscribe((event) => {
if (!(event instanceof NavigationEnd)) return;
if (isInitialLoad) {
isInitialLoad = false;
return;
}
afterNextRender(
() => {
const target =
document.querySelector<HTMLElement>('#main h1') ?? document.getElementById('main');
if (!target) return;
target.setAttribute('tabindex', '-1');
target.focus({ preventScroll: true });
},
{ injector },
);
});
},
};
}
@@ -0,0 +1,81 @@
import { Component, InjectionToken, Type, inject, isDevMode } from '@angular/core';
import { NgComponentOutlet } from '@angular/common';
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';
import { LanguageSwitcherComponent } from '@shared/layout/language-switcher/language-switcher.component';
/** Each app may register its own dev-only "show the Model" panel component here (it's
inherently app-specific it inspects that app's own root stores). No provider
no panel, which is exactly today's behaviour for an app that never had one. */
export const DEBUG_PANEL = new InjectionToken<Type<unknown> | null>('DEBUG_PANEL', {
factory: () => null,
});
/** 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,
LanguageSwitcherComponent,
NgComponentOutlet,
],
styles: [
`
:host {
display: block;
}
.skip {
position: absolute;
left: var(--app-skip-link-offset);
z-index: 1030;
}
.skip:focus {
left: var(--rhc-space-max-md);
top: var(--rhc-space-max-md);
background: var(--rhc-color-wit);
padding: var(--rhc-space-max-sm) var(--rhc-space-max-md);
border-radius: var(--rhc-border-radius-sm);
}
.layout {
display: flex;
flex-direction: column;
min-block-size: 100vh;
}
.main {
flex: 1;
inline-size: 100%;
box-sizing: border-box;
}
.content {
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;
}
`,
],
template: `
<a href="#main" class="skip" i18n="@@shell.skipLink">Naar de inhoud</a>
<app-language-switcher />
<div class="layout">
<app-site-header />
<main id="main" class="main">
<div class="content">
<router-outlet />
</div>
</main>
<app-site-footer />
</div>
@if (isDev && debugPanel) {
<ng-container *ngComponentOutlet="debugPanel" />
}
`,
})
export class ShellComponent {
protected readonly isDev = isDevMode();
protected readonly debugPanel = inject(DEBUG_PANEL);
}
@@ -0,0 +1,29 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideRouter } from '@angular/router';
import { AccessStore } from '@shared/application/access.store';
import { FeatureFlagStore } from '@shared/application/feature-flags.store';
import { ShellComponent } from './shell.component';
const meta: Meta<ShellComponent> = {
title: 'Design System/Templates/Shell',
component: ShellComponent,
// The persistent header injects AccessStore (for its capability-gated admin links) and
// FeatureFlagStore (WP-47, for the Inschrijven nav gate); stub both so the story needs no
// HTTP/ApiClient. `can` false → no admin links; `enabled` true → Inschrijven stays visible.
decorators: [
applicationConfig({
providers: [
provideRouter([]),
{ provide: AccessStore, useValue: { can: () => false } },
{ provide: FeatureFlagStore, useValue: { enabled: () => true } },
],
}),
],
};
export default meta;
type Story = StoryObj<ShellComponent>;
// No route matches, so <router-outlet> renders nothing — this story is about the
// persistent chrome (skip-link, header, footer), not routed page content.
export const Default: Story = {};
@@ -0,0 +1,127 @@
import { Component } from '@angular/core';
/** Organism: Rijksoverheid-style site footer dark-blue, with the
"De Rijksoverheid. Voor Nederland." tagline, responsible-ministry attribution,
and a small "Over deze site" link column. ponytail: links point at the real
rijksoverheid.nl pages, not a fabricated dead-link forest. */
@Component({
selector: 'app-site-footer',
styles: [
`
:host {
display: block;
}
.bar {
background: var(--rhc-color-layout);
color: var(--rhc-color-foreground-on-primary);
margin-block-start: var(--rhc-space-max-5xl);
inline-size: 100%;
}
.inner {
max-inline-size: var(--app-content-max);
margin-inline: auto;
padding: var(--rhc-space-max-3xl) var(--rhc-space-max-2xl);
box-sizing: border-box;
display: flex;
gap: var(--rhc-space-max-3xl);
flex-wrap: wrap;
justify-content: space-between;
}
.tagline {
font-style: italic;
font-weight: var(--rhc-text-font-weight-semi-bold);
font-size: var(--rhc-text-font-size-lg);
max-inline-size: 18rem;
}
.ministry {
margin-block-start: var(--rhc-space-max-md);
font-size: var(--rhc-text-font-size-sm);
color: var(--rhc-color-foreground-on-primary);
}
/* CIBG's vendored h2 tag rule (dark navy, for light backgrounds) beats inherited
color regardless of specificity restate on-primary explicitly for this dark bar. */
.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: 0.04em;
color: var(--rhc-color-foreground-on-primary);
}
.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;
}
/* CIBG's vendored .meta class (unrelated component, coincidental name) sets a
dark grey override rather than rename to keep the CIBG-mirroring class name. */
.meta {
inline-size: 100%;
border-block-start: var(--rhc-border-width-sm) solid
color-mix(in srgb, var(--rhc-color-foreground-on-primary) 25%, transparent);
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: 0.85;
color: var(--rhc-color-foreground-on-primary);
}
`,
],
template: `
<footer class="bar">
<div class="inner">
<div>
<div class="tagline" i18n="@@footer.tagline">De Rijksoverheid. Voor Nederland.</div>
<div class="ministry" i18n="@@footer.ministry">
CIBG Ministerie van Volksgezondheid, Welzijn en Sport
</div>
</div>
<nav class="col" i18n-aria-label="@@footer.overSiteAria" aria-label="Over deze site">
<h2 i18n="@@footer.overSite">Over deze site</h2>
<ul class="links">
<li>
<a
href="https://www.rijksoverheid.nl/privacy"
rel="noopener"
target="_blank"
i18n="@@footer.privacy"
>Privacy</a
>
</li>
<li>
<a
href="https://www.rijksoverheid.nl/cookies"
rel="noopener"
target="_blank"
i18n="@@footer.cookies"
>Cookies</a
>
</li>
<li>
<a
href="https://www.rijksoverheid.nl/toegankelijkheid"
rel="noopener"
target="_blank"
i18n="@@footer.toegankelijkheid"
>Toegankelijkheid</a
>
</li>
</ul>
</nav>
<div class="meta" i18n="@@footer.demo">Demo / POC geen echte gegevens.</div>
</div>
</footer>
`,
})
export class SiteFooterComponent {}
@@ -0,0 +1,12 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { SiteFooterComponent } from './site-footer.component';
const meta: Meta<SiteFooterComponent> = {
title: 'Design System/Organisms/Site Footer',
component: SiteFooterComponent,
render: () => ({ template: `<app-site-footer />` }),
};
export default meta;
type Story = StoryObj<SiteFooterComponent>;
export const Default: Story = {};
@@ -0,0 +1,33 @@
import { InjectionToken } from '@angular/core';
import { Capability } from '@shared/domain/capability';
export interface HeaderNavItem {
readonly label: string;
readonly to: string;
/** Hidden when this feature flag is off (e.g. WP-47's Inschrijven gate). Omit for an
always-visible item. */
readonly flag?: string;
}
/** One admin page: its label, a short description, its route, and the capability that
gates it. Consumed by the site header's admin nav AND (per app) a dashboard's own
Beheer section, both filtered by `AccessStore.can`. Capability-gated, never
role-derived (PRD-0002 §6): the FE only mirrors server-resolved capabilities. */
export interface AdminLink {
readonly label: string;
readonly description: string;
readonly to: string;
readonly cap: Capability;
}
/** Each app supplies its own primary nav the set of top-level routes differs per app
(e.g. the SSP's "Herregistratie"/"Inschrijven" vs. behandelportal's own). */
export const HEADER_NAV_ITEMS = new InjectionToken<readonly HeaderNavItem[]>('HEADER_NAV_ITEMS', {
factory: () => [],
});
/** Each app supplies its own admin links which admin pages exist differs per app
(e.g. only the SSP has a brief/huisstijl editor). */
export const HEADER_ADMIN_LINKS = new InjectionToken<readonly AdminLink[]>('HEADER_ADMIN_LINKS', {
factory: () => [],
});
@@ -0,0 +1,127 @@
import { Component, computed, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router, RouterLink, RouterLinkActive } from '@angular/router';
import { filter, map } from 'rxjs/operators';
import { SESSION_PORT } from '@shared/application/session.port';
import { AccessStore } from '@shared/application/access.store';
import { FeatureFlagStore } from '@shared/application/feature-flags.store';
import { BreadcrumbComponent } from '@shared/layout/breadcrumb/breadcrumb.component';
import { trailFor } from '@shared/layout/breadcrumb/breadcrumb-trail';
import { HEADER_ADMIN_LINKS, HEADER_NAV_ITEMS } from './nav-config';
/** Organism: CIBG Huisstijl site header logo block, robijn titlebar (breadcrumb +
user menu), horizontal nav. ponytail: text wordmark, not the licensed Rijksoverheid
beeldmerk; no search box (no search feature yet). */
@Component({
selector: 'app-site-header',
imports: [RouterLink, RouterLinkActive, BreadcrumbComponent],
styles: [
`
.logout {
background: none;
border: 0;
padding: 0;
cursor: pointer;
text-decoration: underline;
font: inherit;
color: inherit;
}
/* CIBG's header nav has no bg by default in this build the grey bar is ours.
(.titlebar keeps its own robijn fill --ro-layout untouched; the breadcrumb
inside it has no background of its own, so the bar's colour shows through.) */
nav {
background-color: var(--rhc-color-cool-grey-200);
}
`,
],
template: `
<header>
<div class="logo">
<div class="logo__wrapper">
<a routerLink="/dashboard" class="logo__link">
<figure class="logo__figure">
<figcaption class="logo__text">
<span class="logo__sender" i18n="@@header.sender">BIG-register</span>
<span class="logo__ministry" i18n="@@header.ministry"
>Ministerie van Volksgezondheid, Welzijn en Sport</span
>
</figcaption>
</figure>
</a>
</div>
</div>
<div class="titlebar">
<div class="container">
<div class="row">
<div class="title col-md-7">
@if (trail().length) {
<app-breadcrumb [items]="trail()" />
}
</div>
<div class="user-menu col-md-5">
@if (session(); as s) {
<div>
<span class="login-name">{{ s.naam }}</span>
</div>
<div>
<button type="button" class="logout" (click)="logout()" i18n="@@header.uitloggen">
Uitloggen
</button>
</div>
}
</div>
</div>
</div>
</div>
<nav i18n-aria-label="@@header.navAria" aria-label="Hoofdnavigatie">
<div class="container">
<ul>
@for (item of navItems(); track item.to) {
<li routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">
<a [routerLink]="item.to">{{ item.label }}</a>
</li>
}
@for (item of adminItems(); track item.to) {
<li routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">
<a [routerLink]="item.to">{{ item.label }}</a>
</li>
}
</ul>
</div>
</nav>
</header>
`,
})
export class SiteHeaderComponent {
private access = inject(AccessStore);
private flags = inject(FeatureFlagStore);
private rawNavItems = inject(HEADER_NAV_ITEMS);
private rawAdminLinks = inject(HEADER_ADMIN_LINKS);
/** Hides an item whose `flag` is off (e.g. the SSP's Inschrijven gate, WP-47) which
items exist, and which carry a flag, is entirely up to the app that provided them. */
protected readonly navItems = computed(() =>
this.rawNavItems.filter((i) => !i.flag || this.flags.enabled(i.flag)),
);
private router = inject(Router);
private sessionPort = inject(SESSION_PORT, { optional: true });
/** Injecting AccessStore here also warms `/me` at app start (the header renders on
every page), so the admin routes' guard usually finds caps already resolved. */
protected adminItems = computed(() => this.rawAdminLinks.filter((i) => this.access.can(i.cap)));
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']);
}
}
@@ -0,0 +1,57 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideRouter } from '@angular/router';
import { AccessStore } from '@shared/application/access.store';
import { FeatureFlagStore } from '@shared/application/feature-flags.store';
import { Capability } from '@shared/domain/capability';
import { HEADER_ADMIN_LINKS, HEADER_NAV_ITEMS } from './nav-config';
import { SiteHeaderComponent } from './site-header.component';
// The header injects AccessStore for the capability-gated admin links and FeatureFlagStore
// (WP-47, for the Inschrijven nav gate); stub both so the story needs no HTTP/ApiClient.
// `can` decides which admin links appear; `enabled` true keeps Inschrijven visible. Nav/admin
// links are app-provided (HEADER_NAV_ITEMS/HEADER_ADMIN_LINKS) — this story supplies a
// representative sample rather than importing a real app's config, keeping the story
// decoupled from any one app.
const withCaps = (caps: Capability[]) =>
applicationConfig({
providers: [
provideRouter([]),
{ provide: AccessStore, useValue: { can: (c: Capability) => caps.includes(c) } },
{ provide: FeatureFlagStore, useValue: { enabled: () => true } },
{
provide: HEADER_NAV_ITEMS,
useValue: [
{ label: 'Overzicht', to: '/dashboard' },
{ label: 'Mijn gegevens', to: '/registratie' },
],
},
{
provide: HEADER_ADMIN_LINKS,
useValue: [
{ label: 'Huisstijl', description: '', to: '/brief/huisstijl', cap: 'orgtemplate:edit' },
{ label: 'Stamdata', description: '', to: '/beheer/stamdata', cap: 'stamdata:edit' },
],
},
],
});
const meta: Meta<SiteHeaderComponent> = {
title: 'Design System/Organisms/Site Header',
component: SiteHeaderComponent,
decorators: [withCaps([])],
render: (args) => ({
props: args,
template: `<app-site-header />`,
}),
};
export default meta;
type Story = StoryObj<SiteHeaderComponent>;
/** Standard user — no admin links. */
export const Default: Story = {};
/** Admin — the capability-gated Huisstijl + Stamdata links appear. */
export const AsAdmin: Story = {
decorators: [withCaps(['orgtemplate:edit', 'stamdata:edit'])],
};
@@ -0,0 +1,205 @@
import { Component, ElementRef, effect, input, output, untracked, viewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
import { StepperComponent } from '@shared/ui/stepper/stepper.component';
/** CIBG procesnavigatie primary-button copy for a non-final step: "Naar stap 2 - Werk".
Shared so every wizard's `primaryLabel` reads the same way. */
export const naarStapLabel = (stepNumber: number, stepLabel: string) =>
$localize`:@@wizard.naarStap:Naar stap ${stepNumber}:nummer: - ${stepLabel}:label:`;
/** A flat validation error pointing at a field: `id` matches the field's anchor. */
export interface WizardError {
readonly id: string;
readonly message: string;
}
export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed';
/**
* Template: the canonical shell every wizard renders into, so they cannot drift.
* It owns the consistent outline CIBG stappenindicator (title merged in) + error
* summary + the horizontal <form> + the CIBG procesnavigatie button row + the
* submitting/submitted/failed states and the a11y focus management.
*
* Presentational and unidirectional: all state stays in the wizard container
* (the Elm-style store). Inputs flow down; the container reacts to the outputs
* and dispatches messages. The step's own fields are projected as the default
* slot; the success screen is projected via [wizardSuccess].
*/
@Component({
selector: 'app-wizard-shell',
imports: [FormsModule, ButtonComponent, AlertComponent, SpinnerComponent, StepperComponent],
// CIBG-GAP EXTENSION: Foutmelding — the vendored build has no error-summary/
// Veldvalidatie list pattern (verified absent from huisstijl.min.css); the
// .es-title/.es-list rules below are the hand-rolled surface, see cibg-gaps.mdx.
// They render inside a vendored `.feedback-error` alert (app-alert).
styles: [
`
.es-title {
margin: 0 0 var(--rhc-space-max-sm);
}
.es-list {
margin: 0;
padding-inline-start: var(--rhc-space-max-xl);
}
/* Default link color doesn't meet contrast on the error-alert's light-red surface. */
.es-list a {
color: var(--rhc-color-lintblauw-700);
}
`,
],
template: `
@switch (status()) {
@case ('editing') {
<app-stepper
class="app-section"
[steps]="steps()"
[current]="current()"
[processName]="processName()"
[stepTitle]="stepTitle()"
(stepSelected)="goToStep.emit($event)"
/>
@if (errors().length) {
<div
#errorSummary
tabindex="-1"
role="alert"
aria-labelledby="wizard-error-title"
class="app-section"
>
<app-alert type="error">
<h3 id="wizard-error-title" class="es-title" i18n="@@wizard.errorTitle">
Er ging iets mis met uw invoer
</h3>
<ul class="es-list">
@for (e of errors(); track e.id) {
<li>
<a [href]="'#' + e.id" (click)="goToField($event, e.id)">{{ e.message }}</a>
</li>
}
</ul>
</app-alert>
</div>
}
<form (ngSubmit)="primary.emit()" class="form-horizontal app-section">
<div class="form-header">
<div class="form-action">
<span class="meta" i18n="@@form.verplichteVelden">* verplichte velden</span>
</div>
</div>
<!-- Wizard pages wrap their field groups in <fieldset>s; CIBG's
".form-horizontal fieldset" gives each a grey #f1f5f9 surface with a 1.25em token-ok: hex named in prose, not a style value
gap. The shell stays group-agnostic and does NOT add its own fieldset (an
outer grey fieldset would hide the white gaps between the page groups). -->
<ng-content />
<hr />
<div class="d-flex flex-column flex-sm-row-reverse">
<div class="m-0">
<app-button type="submit" variant="primary">{{ primaryLabel() }}</app-button>
</div>
@if (canGoBack()) {
<app-button
type="button"
variant="subtle"
class="me-auto"
(click)="back.emit()"
i18n="@@wizard.terugVorige"
>Terug naar vorige stap</app-button
>
}
</div>
<div class="app-section">
<app-button
type="button"
variant="subtle"
(click)="cancel.emit()"
i18n="@@wizard.annuleren"
>Annuleren</app-button
>
</div>
</form>
}
@case ('submitting') {
<app-spinner /> <span>{{ submittingLabel() }}</span>
}
@case ('submitted') {
<ng-content select="[wizardSuccess]" />
}
@case ('failed') {
<app-alert type="error">{{ errorMessage() }}</app-alert>
<div class="app-section">
<app-button variant="secondary" (click)="retry.emit()" i18n="@@wizard.opnieuwProberen"
>Opnieuw proberen</app-button
>
</div>
}
}
`,
})
export class WizardShellComponent {
steps = input.required<string[]>();
current = input.required<number>();
stepTitle = input.required<string>();
/** Overall process name, shown above the step title (e.g. "Herregistratie aanvragen"). */
processName = input('');
status = input.required<WizardStatus>();
primaryLabel = input.required<string>();
canGoBack = input(false);
errors = input<readonly WizardError[]>([]);
errorMessage = input('');
submittingLabel = input($localize`:@@wizard.submitting:Aanvraag wordt verwerkt…`);
primary = output<void>();
back = output<void>();
cancel = output<void>();
retry = output<void>();
/** A visited step number was clicked in the stepper — back-navigation only. */
goToStep = output<number>();
/** 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 stepper = viewChild(StepperComponent);
private errorSummary = viewChild<ElementRef<HTMLElement>>('errorSummary');
constructor() {
// A11y: move focus to the step title when the step changes (skip first run
// so we don't grab focus on initial load). Tracks current(), which is value-
// stable across keystrokes, so typing never steals focus.
let firstStep = true;
effect(() => {
this.current();
if (firstStep) {
firstStep = false;
return;
}
untracked(() => queueMicrotask(() => this.stepper()?.focusTitle()));
});
// A11y: when validation errors first appear (after a failed submit), move
// focus to the error summary so it's announced. Only on the rising edge
// (none → some): typing rebuilds the errors array each keystroke, and
// re-focusing then would scroll the page up mid-edit. The summary keeps
// role="alert", so content changes are still announced without the jump.
let firstErr = true;
let hadErrors = false;
effect(() => {
const has = this.errors().length > 0;
if (firstErr) {
firstErr = false;
hadErrors = has;
return;
}
if (has && !hadErrors)
untracked(() => queueMicrotask(() => this.errorSummary()?.nativeElement.focus()));
hadErrors = has;
});
}
}
@@ -0,0 +1,58 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { WizardShellComponent } from './wizard-shell.component';
const meta: Meta<WizardShellComponent> = {
title: 'Design System/Templates/WizardShell',
component: WizardShellComponent,
render: (args) => ({
props: args,
template: `
<app-wizard-shell
[steps]="steps" [current]="current" [stepTitle]="stepTitle" [processName]="processName" [status]="status"
[primaryLabel]="primaryLabel" [canGoBack]="canGoBack" [errors]="errors" [errorMessage]="errorMessage"
(goToStep)="goToStep($event)">
<p class="rhc-paragraph">Voorbeeld-stapinhoud (de stapvelden worden hier geprojecteerd).</p>
<div wizardSuccess><p class="rhc-paragraph">Uw aanvraag is ontvangen.</p></div>
</app-wizard-shell>`,
}),
parameters: {
cibgGap: true,
docs: {
description: {
component: 'CIBG-gap extension (error summary only) — see Foundations/CIBG Gap Register.',
},
},
},
};
export default meta;
type Story = StoryObj<WizardShellComponent>;
const steps = ['Adres', 'Beroep', 'Controle'];
const base = {
steps,
current: 1,
stepTitle: 'Beroep op basis van uw diploma',
processName: 'Inschrijven in het BIG-register',
primaryLabel: 'Volgende',
canGoBack: true,
errors: [],
errorMessage: '',
goToStep: () => {},
};
export const Editing: Story = { args: { ...base, status: 'editing' } };
export const EditingMetFouten: Story = {
args: {
...base,
status: 'editing',
errors: [
{ id: 'uren', message: 'Vul het aantal gewerkte uren in.' },
{ id: 'diploma', message: 'Kies een diploma.' },
],
},
};
export const Submitting: Story = { args: { ...base, status: 'submitting' } };
export const Submitted: Story = { args: { ...base, status: 'submitted' } };
export const Failed: Story = {
args: { ...base, status: 'failed', errorMessage: 'Het indienen is niet gelukt: netwerkfout.' },
};
+5
View File
@@ -0,0 +1,5 @@
// ponytail: Angular's unit-test builder always resolves a `build` target for compiler
// options, even for a project that's only ever tested, never served — there's no way to
// opt out. This is that target's sole purpose; nothing imports it. Upgrade to a real
// ng-packagr library target if this lib is ever actually built/published.
export {};
@@ -0,0 +1,50 @@
import { Component, input } from '@angular/core';
type AlertType = 'info' | 'ok' | 'warning' | 'error';
// visually-hidden alternative for the status icon (CIBG a11y requirement).
const ICON_LABELS: Record<AlertType, string> = {
info: $localize`:@@alert.icon.info:Informatie`,
ok: $localize`:@@alert.icon.ok:Gelukt`,
warning: $localize`:@@alert.icon.warning:Waarschuwing`,
error: $localize`:@@alert.icon.error:Foutmelding`,
};
/** Atom: alert/message banner the CIBG Huisstijl "melding"
(designsystem.cibg.nl/componenten/meldingen). Thin wrapper over the vendored
`.feedback feedback-*` classes: the design system owns surface + icon; we add
only the icon's a11y label and a content wrapper (`.feedback` is a flex row).
Errors are `role="alert"` (assertive interrupts) since they need immediate
attention; other variants stay `role="status"` (polite) so success/info banners
don't interrupt what the user is doing. */
@Component({
selector: 'app-alert',
styles: [
`
.feedback > div {
flex: 1 1 auto;
min-width: 0;
}
`,
],
template: `
<div
class="feedback"
[class.feedback-info]="type() === 'info'"
[class.feedback-success]="type() === 'ok'"
[class.feedback-warning]="type() === 'warning'"
[class.feedback-error]="type() === 'error'"
[attr.role]="type() === 'error' ? 'alert' : 'status'"
aria-atomic="true"
>
<span class="icon"
><span class="visually-hidden">{{ iconLabels[type()] }}</span></span
>
<div><ng-content /></div>
</div>
`,
})
export class AlertComponent {
type = input<AlertType>('info');
protected readonly iconLabels = ICON_LABELS;
}
+40
View File
@@ -0,0 +1,40 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { expect, within } from 'storybook/test';
import { AlertComponent } from './alert.component';
const meta: Meta<AlertComponent> = {
title: 'Design System/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>;
// role assertions guard the polite/assertive split (WP-16): errors interrupt, others don't.
export const Info: Story = {
args: { type: 'info' },
play: async ({ canvasElement }) => {
await expect(within(canvasElement).getByRole('status')).toBeInTheDocument();
},
};
export const Ok: Story = {
args: { type: 'ok' },
play: async ({ canvasElement }) => {
await expect(within(canvasElement).getByRole('status')).toBeInTheDocument();
},
};
export const Warning: Story = {
args: { type: 'warning' },
play: async ({ canvasElement }) => {
await expect(within(canvasElement).getByRole('status')).toBeInTheDocument();
},
};
export const Error: Story = {
args: { type: 'error' },
play: async ({ canvasElement }) => {
await expect(within(canvasElement).getByRole('alert')).toBeInTheDocument();
},
};
@@ -0,0 +1,84 @@
import { Component, input, output } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { RouterLink } from '@angular/router';
// CIBG-GAP EXTENSION: Aanvragen (non-navigating row) — the vendored
// `.dashboard-block.applications li a` chain only styles `<a>`; `.static-row`
// mirrors it from tokens for the non-navigating case, see cibg-gaps.mdx.
/** Molecule: one row in a CIBG Huisstijl "aanvragen" list
(designsystem.cibg.nl/componenten/aanvragen) a white card-link styled by the
vendored `.dashboard-block.applications li a` chain (bg, chevron, link-blue `h3`),
with an optional `.subtitle`/`.status`/`.cta`. Used on an `<li>` so the `<ul>`'s
direct child is a native `<li>` (keeps the list axe-clean a bare custom element
between `<ul>` and its `<li>` trips axe's list rule regardless of `display:contents`).
A non-navigating row renders a `<div>` (the vendored chain only styles `<a>`, so
that surface is mirrored from tokens). A `[applicationActions]` slot projects a
sibling action after the anchor a button can't nest inside the anchor itself. */
@Component({
selector: 'li[app-application-link]',
imports: [RouterLink, NgTemplateOutlet],
styles: [
`
/* The vendored .applications li a surface only styles <a>; mirror it from tokens
for a non-navigating (informational) row so the card looks consistent. */
.static-row {
display: flex;
background: var(--rhc-color-wit);
border-block-end: 0.065rem solid var(--rhc-color-border-subtle);
padding: 0.75rem 2rem 0.75rem 1rem;
}
.content {
flex: 1 1 auto;
min-inline-size: 0;
}
.cta {
margin-inline-start: auto;
align-self: center;
}
`,
],
template: `
@if (to()) {
<a [routerLink]="to()"><ng-container [ngTemplateOutlet]="body" /></a>
} @else if (clickable()) {
<a href="#" (click)="onActivate($event)"><ng-container [ngTemplateOutlet]="body" /></a>
} @else {
<div class="static-row"><ng-container [ngTemplateOutlet]="body" /></div>
}
<ng-content select="[applicationActions]" />
<ng-template #body>
<div class="content">
<!-- Raw <h3>, not <app-heading>: the vendored ".applications li a h3" chain styles
the bare h3 (link-blue); an app-heading host wrapper would sit between and can
break that selector. Documented in atomic-design.mdx (convergence decisions). -->
<h3 class="h3">{{ heading() }}</h3>
@if (subtitle()) {
<div class="subtitle">{{ subtitle() }}</div>
}
@if (status()) {
<div class="status">{{ status() }}</div>
}
</div>
@if (cta()) {
<div class="cta">{{ cta() }}</div>
}
</ng-template>
`,
})
export class ApplicationLinkComponent {
heading = input.required<string>();
subtitle = input('');
status = input('');
cta = input('');
/** Set for a plain routerLink navigation (e.g. the "Wat wilt u doen?" actions). */
to = input('');
/** Set when the row navigates imperatively (e.g. resume with query params) the
row still renders as a clickable `<a>`, but `activate` decides what happens. */
clickable = input(false);
activate = output<void>();
protected onActivate(ev: Event) {
ev.preventDefault(); // fragment href resolves against <base href>, not the route
this.activate.emit();
}
}
@@ -0,0 +1,40 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideRouter } from '@angular/router';
import { ApplicationLinkComponent } from './application-link.component';
const meta: Meta<ApplicationLinkComponent> = {
title: 'Design System/Molecules/Application Link',
component: ApplicationLinkComponent,
decorators: [applicationConfig({ providers: [provideRouter([])] })],
render: (args) => ({
props: args,
// Rows are <li>s in the "aanvragen" list — a real <ul> gives them their layout.
template: `<div class="dashboard-block applications"><ul class="list-unstyled"><li app-application-link [heading]="heading" [subtitle]="subtitle" [status]="status" [cta]="cta" [to]="to" [clickable]="clickable"></li></ul></div>`,
}),
parameters: {
cibgGap: true,
docs: {
description: {
component:
'CIBG-gap extension (non-navigating row only, see NietInteractief) — see Foundations/CIBG Gap Register.',
},
},
},
};
export default meta;
type Story = StoryObj<ApplicationLinkComponent>;
export const Navigatie: Story = {
args: {
heading: 'Inschrijven',
subtitle: 'Schrijf u in in het BIG-register.',
to: '/registreren',
},
};
export const Actie: Story = {
args: { heading: 'Inschrijving', status: 'Stap 2 van 3', cta: 'Verder gaan', clickable: true },
};
export const NietInteractief: Story = {
args: { heading: 'Herregistratie', status: 'Referentie 2024-00123 · ingediend op 12 mei 2024' },
};
@@ -0,0 +1,17 @@
import { Component } from '@angular/core';
/** Molecule: wraps `<app-application-link>` rows in the CIBG Huisstijl "aanvragen"
dashboard block (`.dashboard-block.applications`) see
designsystem.cibg.nl/componenten/aanvragen. Used for both the "Mijn aanvragen"
list and the "Wat wilt u doen?" action list on the dashboard. */
@Component({
selector: 'app-application-list',
template: `
<div class="dashboard-block applications">
<ul class="list-unstyled">
<ng-content />
</ul>
</div>
`,
})
export class ApplicationListComponent {}
@@ -0,0 +1,26 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig, moduleMetadata } from '@storybook/angular';
import { provideRouter } from '@angular/router';
import { ApplicationListComponent } from './application-list.component';
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component';
const meta: Meta<ApplicationListComponent> = {
title: 'Design System/Molecules/Application List',
component: ApplicationListComponent,
decorators: [
applicationConfig({ providers: [provideRouter([])] }),
moduleMetadata({ imports: [ApplicationLinkComponent] }),
],
render: () => ({
template: `
<app-application-list>
<li app-application-link heading="Herregistratie" subtitle="Verlenging van uw BIG-registratie" status="In behandeling · Referentie 2024-00123 · ingediend op 12 mei 2024" to="/aanvraag/1"></li>
<li app-application-link heading="Inschrijving" subtitle="Inschrijving in het BIG-register" status="Goedgekeurd · Referentie 2024-00088" to="/aanvraag/2"></li>
<li app-application-link heading="Inschrijven" subtitle="Schrijf u in in het BIG-register." cta="Start inschrijving" to="/registreren"></li>
</app-application-list>`,
}),
};
export default meta;
type Story = StoryObj<ApplicationListComponent>;
export const Default: Story = {};
+161
View File
@@ -0,0 +1,161 @@
import {
Component,
Directive,
TemplateRef,
computed,
contentChild,
input,
output,
} 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>. Generic so the
$implicit context is typed as the resource's T instead of unknown see
AsyncComponent's contentChild<AsyncLoadedDirective<T>> below, which threads the
host's own T through the query result type. */
@Directive({ selector: '[appAsyncLoaded]' })
export class AsyncLoadedDirective<T = unknown> {
constructor(public tpl: TemplateRef<{ $implicit: T }>) {}
static ngTemplateContextGuard<T>(
_dir: AsyncLoadedDirective<T>,
_ctx: unknown,
): _ctx is { $implicit: T } {
return true;
}
}
@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: `
<div aria-live="polite" [attr.aria-busy]="rd().tag === 'Loading' ? 'true' : null">
@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">{{ errorText() }}</app-alert>
<div style="margin-top:1rem">
<app-button variant="secondary" (click)="retry()">{{ retryText() }}</app-button>
</div>
}
}
@case ('Empty') {
@if (emptyTpl()) {
<ng-container [ngTemplateOutlet]="emptyTpl()!.tpl" />
} @else {
<p>{{ emptyText() }}</p>
}
}
@case ('Success') {
<ng-container
[ngTemplateOutlet]="loadedTpl().tpl"
[ngTemplateOutletContext]="{ $implicit: value() }"
/>
}
}
</div>
`,
})
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);
// Shared/English component: copy lives behind language-agnostic inputs. Defaults are
// localizable via $localize (source = default locale, currently nl); callers may override.
errorText = input($localize`:@@async.error:Er ging iets mis bij het laden van de gegevens.`);
retryText = input($localize`:@@async.retry:Opnieuw proberen`);
emptyText = input($localize`:@@async.empty:Geen gegevens gevonden.`);
loadedTpl = contentChild.required<AsyncLoadedDirective<T>>(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,
}),
);
// [resource]-fed callers get reload() for free. [data]-fed callers (a store's
// combined RemoteData — the component doesn't own that resource) must reload
// it themselves; retryClicked is how they find out a retry was requested.
retryClicked = output<void>();
retry = () => {
const r = this.resource();
if (r && 'reload' in r && typeof (r as { reload?: unknown }).reload === 'function') {
(r as { reload: () => void }).reload();
}
this.retryClicked.emit();
};
}
/** Convenience: import this array to get the wrapper + all slot directives. */
export const ASYNC = [
AsyncComponent,
AsyncLoadedDirective,
AsyncLoadingDirective,
AsyncEmptyDirective,
AsyncErrorDirective,
] as const;
+47
View File
@@ -0,0 +1,47 @@
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: 'Design System/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')) },
};
@@ -0,0 +1,27 @@
import { Component, input } from '@angular/core';
type Variant = 'primary' | 'secondary' | 'subtle' | 'danger' | 'ghost';
/** Atom: button. Thin wrapper over the CIBG/Bootstrap button CSS. */
@Component({
selector: 'app-button',
template: `
<button
[type]="type()"
[disabled]="disabled()"
class="btn"
[class.btn-primary]="variant() === 'primary'"
[class.btn-secondary]="variant() === 'secondary'"
[class.btn-link]="variant() === 'subtle'"
[class.btn-danger]="variant() === 'danger'"
[class.btn-ghost]="variant() === 'ghost'"
>
<ng-content />
</button>
`,
})
export class ButtonComponent {
variant = input<Variant>('primary');
type = input<'button' | 'submit'>('button');
disabled = input(false);
}
@@ -0,0 +1,20 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { ButtonComponent } from './button.component';
const meta: Meta<ButtonComponent> = {
title: 'Design System/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 Ghost: Story = { args: { variant: 'ghost' } };
export const Disabled: Story = { args: { variant: 'primary', disabled: true } };
@@ -0,0 +1,61 @@
import { Component, computed, forwardRef, input } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
// Per-instance fallback ids, so the label's `for` always targets THIS checkbox. The
// CIBG styled checkbox hides the native input and routes clicks through the label, so a
// shared/undefined id silently makes every label toggle the first input — hence a default.
let nextCheckboxId = 0;
/** Atom: a labelled checkbox wired as a form control (ngModel/reactive). Thin
wrapper over the CIBG Huisstijl `.form-check.styled` checkbox CSS; native
input for full keyboard + screen-reader support. */
@Component({
selector: 'app-checkbox',
template: `
<div class="form-check styled">
<input
class="form-check-input"
type="checkbox"
[id]="resolvedId()"
[checked]="value"
[disabled]="disabled"
(change)="onToggle($event)"
(blur)="onTouched()"
/>
<label class="form-check-label" [for]="resolvedId()">{{ label() }}</label>
</div>
`,
providers: [
{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => CheckboxComponent), multi: true },
],
})
export class CheckboxComponent implements ControlValueAccessor {
checkboxId = input<string>();
label = input('');
/** The caller's id, or a unique fallback — never undefined, so labels never collide. */
private autoId = `app-checkbox-${nextCheckboxId++}`;
protected resolvedId = computed(() => this.checkboxId() ?? this.autoId);
value = false;
disabled = false;
onChange: (v: boolean) => void = () => {};
onTouched: () => void = () => {};
onToggle(e: Event) {
this.value = (e.target as HTMLInputElement).checked;
this.onChange(this.value);
}
writeValue(v: boolean) {
this.value = !!v;
}
registerOnChange(fn: (v: boolean) => void) {
this.onChange = fn;
}
registerOnTouched(fn: () => void) {
this.onTouched = fn;
}
setDisabledState(d: boolean) {
this.disabled = d;
}
}
@@ -0,0 +1,15 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { CheckboxComponent } from './checkbox.component';
const meta: Meta<CheckboxComponent> = {
title: 'Design System/Atoms/Checkbox',
component: CheckboxComponent,
render: (args) => ({
props: args,
template: `<app-checkbox [label]="label" [checkboxId]="checkboxId"></app-checkbox>`,
}),
};
export default meta;
type Story = StoryObj<CheckboxComponent>;
export const Default: Story = { args: { label: 'Standaard aanhef', checkboxId: 'cb-1' } };
@@ -0,0 +1,79 @@
import { Component, input, output } from '@angular/core';
import { RouterLink } from '@angular/router';
/** Molecule: one choice in a CIBG Huisstijl "keuzelijst" `<li><div class="keuzelijst__link">`
with a title and optional instructions (see choice-list.component.ts). Renders a
plain (non-interactive) block when there's nothing to navigate to.
The title is a Bootstrap "stretched-link" (`.stretched-link`, vendored) rather than
the whole box being an `<a>`: a `[choiceActions]` slot needs to project a sibling
action (e.g. "Annuleren") *inside* the same card, and a `<button>` can't nest
inside an `<a>` (invalid HTML, broken a11y). stretched-link keeps the entire card
clickable via its `::after` overlay; the projected action sits above that overlay
(see its own `position:relative;z-index:2` at the call site) so it stays clickable.
Unlike the "aanvragen" pattern's `.applications li a::after` (scoped to the `a`
tag), CIBG's `.keuzelijst__link:after`/`:hover`/`:focus` rules key off the bare
class keuzelijst assumes every item IS a link so a non-interactive row would
otherwise inherit the chevron and hover accent too. The `--static` modifier below
suppresses both for that case. `:focus-within` restores the focus accent that
`:focus` would have given the (no longer directly focused) card. */
@Component({
selector: 'app-choice-link',
imports: [RouterLink],
styles: [
`
:host {
display: contents;
}
.keuzelijst__link {
position: relative;
}
.keuzelijst__link:focus-within {
background-color: var(--rhc-color-cool-grey-100);
box-shadow: inset 4px 0 0 0 var(--rhc-color-lintblauw-500);
}
.keuzelijst__link--static::after {
content: none;
}
.keuzelijst__link--static:hover {
background-color: var(--rhc-color-cool-grey-200);
box-shadow: none;
}
`,
],
template: `
<li class="keuzelijst__list-item">
<div class="keuzelijst__link" [class.keuzelijst__link--static]="!to() && !clickable()">
<h3 class="keuzelijst__header">
@if (to()) {
<a class="stretched-link" [routerLink]="to()">{{ heading() }}</a>
} @else if (clickable()) {
<a class="stretched-link" href="#" (click)="onActivate($event)">{{ heading() }}</a>
} @else {
{{ heading() }}
}
</h3>
@if (instructions()) {
<p class="keuzelijst__instructions">{{ instructions() }}</p>
}
<ng-content select="[choiceActions]" />
</div>
</li>
`,
})
export class ChoiceLinkComponent {
heading = input.required<string>();
instructions = input('');
/** Set for a plain routerLink navigation. */
to = input('');
/** Set when the choice navigates imperatively (e.g. resume with query params) the
row still renders as a clickable card, but `activate` decides what happens. */
clickable = input(false);
activate = output<void>();
protected onActivate(ev: Event) {
ev.preventDefault(); // fragment href resolves against <base href>, not the route
this.activate.emit();
}
}
@@ -0,0 +1,40 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideRouter } from '@angular/router';
import { ChoiceLinkComponent } from './choice-link.component';
const meta: Meta<ChoiceLinkComponent> = {
title: 'Design System/Molecules/Choice Link',
component: ChoiceLinkComponent,
decorators: [applicationConfig({ providers: [provideRouter([])] })],
render: (args) => ({
props: args,
// Rows are <li>s — a real list gives them their normal layout in the story.
template: `<ul class="keuzelijst__list"><app-choice-link [heading]="heading" [instructions]="instructions" [to]="to" [clickable]="clickable" /></ul>`,
}),
parameters: {
// Structural: app-choice-link's host sits between the <ul> and its <li> — axe's
// list/listitem rule requires them adjacent regardless of `display:contents`.
// WP-11 (CIBG markup fidelity) reworks this markup; see docs/project/backlog/WP-11-markup-fidelity.md.
a11y: { disable: true },
},
};
export default meta;
type Story = StoryObj<ChoiceLinkComponent>;
export const Navigatie: Story = {
args: {
heading: 'Ik heb een Nederlands diploma',
instructions: 'U kunt direct uw registratie aanvragen.',
to: '/registreren',
},
};
export const Actie: Story = {
args: { heading: 'Inschrijving', instructions: 'Stap 2 van 3', clickable: true },
};
export const NietInteractief: Story = {
args: {
heading: 'Herregistratie',
instructions: 'Referentie 2024-00123 · ingediend op 12 mei 2024',
},
};
@@ -0,0 +1,22 @@
import { Component, input } from '@angular/core';
let nextId = 0;
/** Molecule: the CIBG Huisstijl "keuzelijst" a heading semantically linked
(`aria-labelledby`) to a list of `<app-choice-link>` choices, used where a
screen offers a set of options to pick between (see
designsystem.cibg.nl/componenten/keuzelijst). Domain-free the caller
supplies the heading text and the choices. */
@Component({
selector: 'app-choice-list',
template: `
<h2 class="header header--medium" [id]="headingId">{{ heading() }}</h2>
<ul class="keuzelijst__list" [attr.aria-labelledby]="headingId">
<ng-content />
</ul>
`,
})
export class ChoiceListComponent {
heading = input.required<string>();
protected readonly headingId = `choice-list-${nextId++}`;
}
@@ -0,0 +1,31 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig, moduleMetadata } from '@storybook/angular';
import { provideRouter } from '@angular/router';
import { ChoiceListComponent } from './choice-list.component';
import { ChoiceLinkComponent } from '@shared/ui/choice-link/choice-link.component';
const meta: Meta<ChoiceListComponent> = {
title: 'Design System/Molecules/Choice List',
component: ChoiceListComponent,
decorators: [
applicationConfig({ providers: [provideRouter([])] }),
moduleMetadata({ imports: [ChoiceLinkComponent] }),
],
render: (args) => ({
props: args,
template: `
<app-choice-list [heading]="heading">
<app-choice-link heading="Ik heb een Nederlands diploma" instructions="U kunt direct uw registratie aanvragen." to="/registreren" />
<app-choice-link heading="Ik heb een buitenlands diploma" instructions="Uw diploma moet eerst officieel erkend worden." clickable="true" />
</app-choice-list>`,
}),
parameters: {
// Structural: app-choice-link's host sits between the <ul> and its <li> —
// fixed by the WP-11 markup rework. See docs/project/backlog/WP-11-markup-fidelity.md.
a11y: { disable: true },
},
};
export default meta;
type Story = StoryObj<ChoiceListComponent>;
export const Default: Story = { args: { heading: 'Maak een keuze' } };
@@ -0,0 +1,32 @@
import { Component, input } from '@angular/core';
/** Molecule: the CIBG Huisstijl "bevestiging" (confirmation) an animated green
checkmark banner shown at the end of an aanvraagproces, ONLY when the user has
nothing left to do (see designsystem.cibg.nl/componenten/bevestiging). Follow-up
content (a reference number, a restart button) is projected below the banner. */
@Component({
selector: 'app-confirmation',
template: `
<div class="confirmation">
<svg
class="confirmation__checkmark"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 52 52"
height="52"
width="52"
>
<circle class="confirmation__checkmark-circle" cx="26" cy="26" r="18" fill="none" />
<path class="confirmation__checkmark-check" fill="none" d="M14.1 27.2l7.1 7.2 16.7-16.8" />
</svg>
<div class="confirmation__title">
<span class="visually-hidden">{{ successPrefix() }}</span
>{{ title() }}
</div>
</div>
<ng-content />
`,
})
export class ConfirmationComponent {
title = input.required<string>();
successPrefix = input($localize`:@@confirmation.succes:Succes:`);
}
@@ -0,0 +1,18 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { ConfirmationComponent } from './confirmation.component';
const meta: Meta<ConfirmationComponent> = {
title: 'Design System/Molecules/Confirmation',
component: ConfirmationComponent,
render: (args) => ({
props: args,
template: `
<app-confirmation [title]="title">
<p class="app-section">Uw referentienummer is 2024-00123. Bewaar dit nummer voor uw administratie.</p>
</app-confirmation>`,
}),
};
export default meta;
type Story = StoryObj<ConfirmationComponent>;
export const Default: Story = { args: { title: 'Uw aanvraag is verstuurd' } };
@@ -0,0 +1,36 @@
import { Component, input } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
/** Molecule: CIBG Huisstijl **Datablock** (designsystem.cibg.nl/componenten/datablock)
THE way to show user/application data. A grey `.data-block` surface holds a white
`.block-wrapper` panel with a `<dl>` of projected `<app-data-row>`s. Use `stacked`
(`.data-block--stacked`) when labels/values are long and should stack. This is the
single data surface (a generic white `app-card` used to exist but was unused and
removed see WP-12); the datablock carries its own surface, so it is not nested in
another one. When there is no visible `heading`, pass an `ariaLabel` so the definition
list is announced. */
@Component({
selector: 'app-data-block',
imports: [HeadingComponent],
template: `
@if (heading()) {
<app-heading [level]="level()">{{ heading() }}</app-heading>
}
<div class="data-block" [class.data-block--stacked]="stacked()">
<div class="block-wrapper">
<dl class="mb-0" [attr.aria-label]="ariaLabel() || null">
<ng-content />
</dl>
</div>
</div>
`,
})
export class DataBlockComponent {
heading = input('');
/** Heading level when `heading` is set (default h3). */
level = input<1 | 2 | 3 | 4 | 5>(3);
/** Stacks label above value (`.data-block--stacked`) for long content. */
stacked = input(false);
/** Accessible name for the `<dl>` when there is no visible heading. */
ariaLabel = input('');
}
@@ -0,0 +1,26 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { moduleMetadata } from '@storybook/angular';
import { DataBlockComponent } from './data-block.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
const meta: Meta<DataBlockComponent> = {
title: 'Design System/Molecules/Data Block',
component: DataBlockComponent,
decorators: [moduleMetadata({ imports: [DataRowComponent] })],
render: (args) => ({
props: args,
template: `
<app-data-block [heading]="heading" [stacked]="stacked" [ariaLabel]="ariaLabel">
<div app-data-row key="BIG-nummer" value="19012345601"></div>
<div app-data-row key="Naam" value="J. de Vries"></div>
<div app-data-row key="Beroep" value="Verpleegkundige"></div>
<div app-data-row key="Registratiedatum" value="1 maart 2018"></div>
</app-data-block>`,
}),
};
export default meta;
type Story = StoryObj<DataBlockComponent>;
export const Default: Story = { args: { heading: 'Persoonsgegevens (BRP)' } };
export const ZonderKop: Story = { args: { ariaLabel: 'Registratiegegevens' } };
export const Stacked: Story = { args: { heading: 'Toelichting', stacked: true } };
@@ -0,0 +1,35 @@
import { Component, input } from '@angular/core';
/** Molecule: one key/value row inside a CIBG Huisstijl **Datablock** (see
`data-block.component.ts`) the row primitive of the datablock/`controlestap`
data summary. Used on a `<div>` so the `<dl>`'s direct child is a native element
(the HTML5.1 `dl > div > dt + dd` grouping), which keeps the definition list
axe-clean a bare custom element between `<dl>` and its `<dt>/<dd>` trips axe's
definition-list rule regardless of `display:contents`. The host is the Bootstrap
`.row`; `dt.col-md-4`/`dd.col-md-8` give the label/value widths. Wrap several in a
`<dl class="mb-0">` (a `<app-data-block>`). Project custom content (e.g. a badge)
into the `<dd>` via `<ng-content>`. */
@Component({
selector: 'div[app-data-row]',
host: { class: 'row' },
// The CIBG datablock draws a separator between entries. It ships that border on
// dt/dd with a `:last-of-type` reset, but our one-row-per-<div> grouping (for axe)
// makes every dt/dd a last-of-type — so we carry the separator on the row instead.
styles: [
`
:host:not(:last-of-type) {
border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-cool-grey-200);
}
`,
],
template: `
<dt class="col-md-4">{{ key() }}</dt>
<dd class="col-md-8">
<ng-content>{{ value() }}</ng-content>
</dd>
`,
})
export class DataRowComponent {
key = input.required<string>();
value = input<string | null>('');
}
@@ -0,0 +1,19 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { DataRowComponent } from './data-row.component';
const meta: Meta<DataRowComponent> = {
title: 'Design System/Molecules/Data Row',
component: DataRowComponent,
render: (args) => ({
props: args,
// A row is a `.row` <div> grouping dt.col-md-4/dd.col-md-8 inside the datablock <dl>
// (HTML5.1 dl > div > dt+dd — a native div child keeps the definition list axe-clean).
template: `<dl class="mb-0"><div app-data-row [key]="key" [value]="value"></div></dl>`,
}),
args: { key: 'BIG-nummer', value: '19012345601' },
};
export default meta;
type Story = StoryObj<DataRowComponent>;
export const Default: Story = {};
export const Empty: Story = { args: { key: 'Tweede naam', value: '' } };
@@ -0,0 +1,35 @@
import { Component, booleanAttribute, input } from '@angular/core';
/** Molecule: form field = label + projected control + optional error/description,
in the CIBG Huisstijl horizontal `form-group row` layout (label `col-md-4`,
control `col-md-8`). The required asterisk comes from
`.form-group.required>.col-form-label::after` no separate "(verplicht)" text.
Reused by the login form, the change-request form, and every wizard step. */
@Component({
selector: 'app-form-field',
template: `
<div class="form-group row" [class.required]="required()">
<label class="col-md-4 col-form-label" [id]="fieldId() + '-label'" [for]="fieldId()">{{
label()
}}</label>
<div class="col-md-8 col-control">
@if (description()) {
<div class="form-text" [id]="fieldId() + '-desc'">{{ description() }}</div>
}
<ng-content />
@if (error()) {
<div [id]="fieldId() + '-error'" role="alert">
<span class="errortext">{{ error() }}</span>
</div>
}
</div>
</div>
`,
})
export class FormFieldComponent {
label = input.required<string>();
fieldId = input.required<string>();
description = input<string>();
error = input<string>();
required = input(false, { transform: booleanAttribute });
}
@@ -0,0 +1,61 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { moduleMetadata } from '@storybook/angular';
import { expect, within } from 'storybook/test';
import { FormFieldComponent } from './form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
const meta: Meta<FormFieldComponent> = {
title: 'Design System/Molecules/Form Field',
component: FormFieldComponent,
decorators: [moduleMetadata({ imports: [TextInputComponent] })],
render: (args) => ({
props: args,
// form-horizontal + .row context, same as every real caller (wizard-shell, login-form, …).
template: `
<form class="form-horizontal">
<app-form-field [label]="label" [fieldId]="fieldId" [description]="description" [error]="error" [required]="required">
<app-text-input [inputId]="fieldId" [hasDescription]="!!description" [invalid]="!!error" placeholder="Vul in" />
</app-form-field>
</form>`,
}),
};
export default meta;
type Story = StoryObj<FormFieldComponent>;
export const Default: Story = {
args: { label: 'BSN', fieldId: 'bsn', description: '9 cijfers', required: true },
// Composition contract: fieldId must equal the input's id — enforced here, not by DI
// (see WP-16). Catches drift in the description→aria-describedby wiring.
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByRole('textbox');
await expect(input).toHaveAttribute('aria-describedby', 'bsn-desc');
},
};
export const WithError: Story = {
args: {
label: 'Straat en huisnummer',
fieldId: 'street',
error: 'Dit veld is verplicht.',
required: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByRole('textbox');
await expect(input).toHaveAttribute('aria-describedby', 'street-error');
},
};
export const WithDescriptionAndError: Story = {
args: {
label: 'BSN',
fieldId: 'bsn',
description: '9 cijfers',
error: 'Ongeldig BSN.',
required: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByRole('textbox');
await expect(input).toHaveAttribute('aria-describedby', 'bsn-desc bsn-error');
},
};
@@ -0,0 +1,33 @@
import { Component, input } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
/** Atom: heading. Renders the right h1..h5; CIBG/Bootstrap styles native headings.
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><ng-container [ngTemplateOutlet]="content" /></h1>
}
@case (2) {
<h2><ng-container [ngTemplateOutlet]="content" /></h2>
}
@case (3) {
<h3><ng-container [ngTemplateOutlet]="content" /></h3>
}
@case (4) {
<h4><ng-container [ngTemplateOutlet]="content" /></h4>
}
@default {
<h5><ng-container [ngTemplateOutlet]="content" /></h5>
}
}
`,
})
export class HeadingComponent {
level = input<1 | 2 | 3 | 4 | 5>(2);
}
@@ -0,0 +1,17 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { HeadingComponent } from './heading.component';
const meta: Meta<HeadingComponent> = {
title: 'Design System/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 } };
+12
View File
@@ -0,0 +1,12 @@
import { Component, input } from '@angular/core';
import { RouterLink } from '@angular/router';
/** Atom: link. Internal router link; CIBG/Bootstrap styles bare anchors. */
@Component({
selector: 'app-link',
imports: [RouterLink],
template: `<a [routerLink]="to()" class="link-primary"><ng-content /></a>`,
})
export class LinkComponent {
to = input.required<string>();
}
+19
View File
@@ -0,0 +1,19 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideRouter } from '@angular/router';
import { LinkComponent } from './link.component';
const meta: Meta<LinkComponent> = {
title: 'Design System/Atoms/Link',
component: LinkComponent,
decorators: [applicationConfig({ providers: [provideRouter([])] })],
render: (args) => ({
props: args,
template: `<app-link [to]="to">Naar het dashboard</app-link>`,
}),
args: { to: '/dashboard' },
};
export default meta;
type Story = StoryObj<LinkComponent>;
export const Default: Story = {};
@@ -0,0 +1,32 @@
import { Component, computed, input, output } from '@angular/core';
import { ButtonComponent } from '@shared/ui/button/button.component';
/**
* Atom: a possibly-masked sensitive value (BSN, BIG-nummer, ) with an optional, audited
* reveal affordance (WP-40). The value arrives masked from the server (data-minimisation)
* and is swapped for the full value on reveal; the reveal button shows only when the value
* is still masked AND the caller says the principal may reveal it. Centralises the
* masked-detection that consumers used to sniff inline. The atom only emits `reveal`; the
* caller owns the step-up gesture + the audited fetch (see behandel-scherm).
*
* ponytail: masked-detection is the mask character (`*`) a POC heuristic. A server-sent
* `masked` boolean would remove the sniff; wire it here without touching consumers.
*/
@Component({
selector: 'app-masked-value',
imports: [ButtonComponent],
template: `
<span class="value">{{ value() }}</span>
@if (canReveal() && masked()) {
<app-button variant="subtle" (click)="reveal.emit()">{{ revealLabel() }}</app-button>
}
`,
})
export class MaskedValueComponent {
value = input.required<string>();
canReveal = input(false);
revealLabel = input($localize`:@@maskedValue.reveal:Tonen`);
reveal = output<void>();
protected masked = computed(() => this.value().includes('*'));
}
@@ -0,0 +1,24 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { MaskedValueComponent } from './masked-value.component';
const meta: Meta<MaskedValueComponent> = {
title: 'Design System/Atoms/Masked Value',
component: MaskedValueComponent,
};
export default meta;
type Story = StoryObj<MaskedValueComponent>;
/** Masked + the principal may reveal → the reveal button shows. */
export const RevealableMasked: Story = {
args: { value: '******601', canReveal: true, revealLabel: 'Toon BIG-nummer' },
};
/** Masked but no reveal right → just the masked value, no affordance. */
export const MaskedNoReveal: Story = {
args: { value: '******601', canReveal: false },
};
/** Already revealed (no mask character) → no reveal button even with the right. */
export const Revealed: Story = {
args: { value: '990000000012', canReveal: true },
};
@@ -0,0 +1,85 @@
import { Component, computed, input } from '@angular/core';
// CIBG-GAP EXTENSION: n/a — no vendored inline-chip/tag class; hand-rolled
// brace-wrapped chip, see cibg-gaps.mdx.
/** Atom: a highlighted, non-editable placeholder chip for READ-ONLY rendering
(preview, diagnostics). Distinct styling for auto-resolvable vs manual fields and
for linter error/warning states. Domain-free and presentational the caller
passes label/state; a11y label announces the field name + its resolution status.
(The editor renders its own inline chips inside contenteditable; this atom is for
everywhere the letter is shown, not edited.) */
@Component({
selector: 'app-placeholder-chip',
styles: [
`
.chip {
display: inline-flex;
align-items: center;
gap: 0.2em;
border-radius: var(--rhc-border-radius-sm);
padding: 0 0.35em;
line-height: 1.6;
border: 1px solid transparent;
white-space: nowrap;
}
/* Braces use unicode escapes; a literal { in a CSS content string breaks the style parser. */
.chip::before {
content: '\\7B';
opacity: 0.6;
font-weight: 700;
}
.chip::after {
content: '\\7D';
opacity: 0.6;
font-weight: 700;
}
.chip--auto {
background: var(--rhc-color-cool-grey-100);
color: var(--rhc-color-foreground-default);
}
.chip--manual {
background: var(--rhc-color-geel-100);
color: var(--rhc-color-foreground-default);
}
.chip--warning {
background: var(--rhc-color-geel-100);
border-color: var(--rhc-color-border-default);
color: var(--rhc-color-foreground-default);
}
.chip--error {
background: var(--rhc-color-rood-100);
border-color: var(--rhc-color-border-default);
color: var(--rhc-color-foreground-default);
}
`,
],
template: `<span class="chip" [class]="'chip--' + variant()" [attr.aria-label]="ariaLabel()">{{
label()
}}</span>`,
})
export class PlaceholderChipComponent {
label = input.required<string>();
autoResolvable = input(false);
state = input<'ok' | 'warning' | 'error'>('ok');
// Copy is localizable-by-default per the shared-UI convention (like <app-async>).
autoText = input($localize`:@@placeholderChip.auto:wordt automatisch ingevuld`);
manualText = input($localize`:@@placeholderChip.manual:handmatig in te vullen`);
warningText = input($localize`:@@placeholderChip.warning:let op`);
errorText = input($localize`:@@placeholderChip.error:fout`);
protected variant = computed(() => {
const s = this.state();
return s !== 'ok' ? s : this.autoResolvable() ? 'auto' : 'manual';
});
protected ariaLabel = computed(() => {
const status = {
auto: this.autoText(),
manual: this.manualText(),
warning: this.warningText(),
error: this.errorText(),
}[this.variant()];
return $localize`:@@placeholderChip.aria:Veld ${this.label()}:label:, ${status}:status:`;
});
}
@@ -0,0 +1,30 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { PlaceholderChipComponent } from './placeholder-chip.component';
const meta: Meta<PlaceholderChipComponent> = {
title: 'Design System/Atoms/Placeholder Chip',
component: PlaceholderChipComponent,
render: (args) => ({
props: args,
template: `<app-placeholder-chip [label]="label" [autoResolvable]="autoResolvable" [state]="state"></app-placeholder-chip>`,
}),
parameters: {
cibgGap: true,
docs: { description: { component: 'CIBG-gap extension — see Foundations/CIBG Gap Register.' } },
},
};
export default meta;
type Story = StoryObj<PlaceholderChipComponent>;
export const AutoResolvable: Story = {
args: { label: 'Naam zorgverlener', autoResolvable: true, state: 'ok' },
};
export const Manual: Story = {
args: { label: 'Reden besluit', autoResolvable: false, state: 'ok' },
};
export const Warning: Story = {
args: { label: 'Oud kenmerk', autoResolvable: true, state: 'warning' },
};
export const Error: Story = {
args: { label: 'Onbekend veld', autoResolvable: false, state: 'error' },
};

Some files were not shown because too many files have changed in this diff Show More