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:
@@ -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.' },
|
||||
};
|
||||
@@ -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.' },
|
||||
};
|
||||
Reference in New Issue
Block a user