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,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.' },
};