Add registratie wizard, BFF dashboard-view, contracts/value-objects, and architecture docs
Checkpoint of in-progress work: the registration wizard (address prefill, DUO diploma lookup, policy questions), decision-DTO contracts, parse-don't- validate value objects, infrastructure adapters, plus CLAUDE.md and the architecture/ADR docs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
39
src/app/shared/layout/breadcrumb/breadcrumb.component.ts
Normal file
39
src/app/shared/layout/breadcrumb/breadcrumb.component.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
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. Renders the RHC/Utrecht breadcrumb pattern; the
|
||||
last item is the current page (no link, aria-current). Domain-free — the caller
|
||||
supplies the trail. */
|
||||
@Component({
|
||||
selector: 'app-breadcrumb',
|
||||
imports: [RouterLink],
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.list{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-md);align-items:center;list-style:none;margin:0;padding:0}
|
||||
.sep{color:var(--rhc-color-foreground-subtle)}
|
||||
`],
|
||||
template: `
|
||||
<nav class="rhc-breadcrumb-nav utrecht-breadcrumb-nav" aria-label="Kruimelpad">
|
||||
<ol class="utrecht-breadcrumb-nav__list list">
|
||||
@for (item of items(); track item.label; let last = $last) {
|
||||
<li class="utrecht-breadcrumb-nav__item">
|
||||
@if (item.link && !last) {
|
||||
<a class="utrecht-breadcrumb-nav__link rhc-link nl-link" [routerLink]="item.link">{{ item.label }}</a>
|
||||
<span class="sep" aria-hidden="true">/</span>
|
||||
} @else {
|
||||
<span class="utrecht-breadcrumb-nav__link rhc-breadcrumb-nav__link--current" aria-current="page">{{ item.label }}</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</nav>
|
||||
`,
|
||||
})
|
||||
export class BreadcrumbComponent {
|
||||
items = input.required<BreadcrumbItem[]>();
|
||||
}
|
||||
23
src/app/shared/layout/breadcrumb/breadcrumb.stories.ts
Normal file
23
src/app/shared/layout/breadcrumb/breadcrumb.stories.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
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: 'Layout/Breadcrumb',
|
||||
component: BreadcrumbComponent,
|
||||
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-breadcrumb [items]="items" />`,
|
||||
}),
|
||||
};
|
||||
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' }] },
|
||||
};
|
||||
@@ -1,22 +1,31 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { LinkComponent } from '@shared/ui/link/link.component';
|
||||
import { BreadcrumbComponent, BreadcrumbItem } from '@shared/layout/breadcrumb/breadcrumb.component';
|
||||
|
||||
/** Template: standard page body — optional back-link, a heading, optional intro,
|
||||
and projected content. Rendered inside the persistent ShellComponent via the
|
||||
router outlet, so it owns only the content (not the header/footer chrome). */
|
||||
/** Template: standard page body — optional breadcrumb, optional back-link, a
|
||||
heading, optional intro, and projected content. Rendered inside the persistent
|
||||
ShellComponent via the router outlet, so it owns only the content (not chrome). */
|
||||
@Component({
|
||||
selector: 'app-page-shell',
|
||||
imports: [HeadingComponent, LinkComponent],
|
||||
styles: [':host{display:block}'],
|
||||
imports: [HeadingComponent, LinkComponent, BreadcrumbComponent],
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.body--narrow{max-inline-size:var(--app-form-narrow)}
|
||||
.crumb{margin-block-end:var(--rhc-space-max-xl)}
|
||||
.intro{margin-block-end:var(--rhc-space-max-2xl)}
|
||||
`],
|
||||
template: `
|
||||
<div [style.max-width]="width() === 'narrow' ? '32rem' : null">
|
||||
<div [class.body--narrow]="width() === 'narrow'">
|
||||
@if (breadcrumb()) {
|
||||
<app-breadcrumb class="crumb" [items]="breadcrumb()!" />
|
||||
}
|
||||
@if (backLink()) {
|
||||
<p><app-link [to]="backLink()!">← {{ backLabel() }}</app-link></p>
|
||||
}
|
||||
<app-heading [level]="1">{{ heading() }}</app-heading>
|
||||
@if (intro()) {
|
||||
<p class="rhc-paragraph" style="margin-bottom:1.5rem">{{ intro() }}</p>
|
||||
<p class="rhc-paragraph intro">{{ intro() }}</p>
|
||||
}
|
||||
<ng-content />
|
||||
</div>
|
||||
@@ -28,4 +37,5 @@ export class PageShellComponent {
|
||||
backLink = input<string>();
|
||||
backLabel = input('Terug naar overzicht');
|
||||
width = input<'default' | 'narrow'>('default');
|
||||
breadcrumb = input<BreadcrumbItem[]>();
|
||||
}
|
||||
|
||||
@@ -8,13 +8,19 @@ import { SiteFooterComponent } from '@shared/layout/site-footer/site-footer.comp
|
||||
@Component({
|
||||
selector: 'app-shell',
|
||||
imports: [RouterOutlet, SiteHeaderComponent, SiteFooterComponent],
|
||||
styles: [':host{display:block}'],
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.skip{position:absolute;left:var(--app-skip-link-offset)}
|
||||
.layout{min-block-size:100vh;align-items:stretch}
|
||||
.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="rhc-skip-link" style="position:absolute;left:-999px">Naar de inhoud</a>
|
||||
<div class="utrecht-page-layout utrecht-page-layout--stretch" style="--app-content-max:64rem;min-height:100vh;align-items:stretch">
|
||||
<a href="#main" class="rhc-skip-link skip">Naar de inhoud</a>
|
||||
<div class="utrecht-page-layout utrecht-page-layout--stretch layout">
|
||||
<app-site-header />
|
||||
<main id="main" class="utrecht-page-content" style="flex:1;width:100%;box-sizing:border-box">
|
||||
<div style="max-width:var(--app-content-max);margin:0 auto;padding:2rem 1.5rem;box-sizing:border-box">
|
||||
<main id="main" class="utrecht-page-content main">
|
||||
<div class="content">
|
||||
<router-outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -3,13 +3,18 @@ import { Component } from '@angular/core';
|
||||
/** Organism: site footer. */
|
||||
@Component({
|
||||
selector: 'app-site-footer',
|
||||
styles: [':host{display:block}'],
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.bar{background:var(--rhc-color-donkerblauw-700);color:var(--rhc-color-wit);margin-block-start:var(--rhc-space-max-5xl);inline-size:100%}
|
||||
.inner{max-inline-size:var(--app-content-max);margin-inline:auto;padding:var(--rhc-space-max-2xl);display:flex;gap:var(--rhc-space-max-3xl);flex-wrap:wrap;box-sizing:border-box}
|
||||
.end{margin-inline-start:auto}
|
||||
`],
|
||||
template: `
|
||||
<footer class="utrecht-page-footer" style="background:var(--rhc-color-lintblauw-900,#01689b);color:#fff;margin-top:3rem;width:100%">
|
||||
<div style="max-width:var(--app-content-max);margin:0 auto;padding:1.5rem;display:flex;gap:2rem;flex-wrap:wrap;box-sizing:border-box">
|
||||
<footer class="utrecht-page-footer bar">
|
||||
<div class="inner">
|
||||
<span>BIG-register</span>
|
||||
<span>CIBG — Ministerie van Volksgezondheid, Welzijn en Sport</span>
|
||||
<span style="margin-left:auto;opacity:0.85">Demo / POC — geen echte gegevens</span>
|
||||
<span class="end">Demo / POC — geen echte gegevens</span>
|
||||
</div>
|
||||
</footer>
|
||||
`,
|
||||
|
||||
@@ -8,17 +8,26 @@ import { RouterLink } from '@angular/router';
|
||||
imports: [RouterLink],
|
||||
// :host display:block so the full-bleed bar fills the flex column (custom
|
||||
// elements default to display:inline, which collapsed the bar to its content).
|
||||
styles: [':host{display:block}'],
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.bar{background:var(--rhc-color-lintblauw-700);color:var(--rhc-color-wit);inline-size:100%}
|
||||
.inner{display:flex;align-items:center;gap:var(--rhc-space-max-xl);max-inline-size:var(--app-content-max);margin-inline:auto;padding:var(--rhc-space-max-xl) var(--rhc-space-max-2xl);box-sizing:border-box}
|
||||
.brand{display:flex;align-items:center;gap:var(--rhc-space-max-lg);color:inherit;text-decoration:none}
|
||||
.mark{display:inline-block;inline-size:var(--rhc-space-max-md);block-size:var(--rhc-space-max-4xl);background:var(--rhc-color-wit)}
|
||||
.name{font-weight:var(--rhc-text-font-weight-bold);line-height:var(--rhc-text-line-height-sm)}
|
||||
.sub{font-weight:var(--rhc-text-font-weight-regular);font-size:var(--rhc-text-font-size-sm)}
|
||||
.portal{margin-inline-start:auto;font-weight:var(--rhc-text-font-weight-semi-bold)}
|
||||
`],
|
||||
template: `
|
||||
<header class="utrecht-page-header" style="background:var(--rhc-color-lintblauw-700,#154273);color:#fff;width:100%">
|
||||
<div style="display:flex;align-items:center;gap:1rem;max-width:var(--app-content-max);margin:0 auto;padding:1rem 1.5rem;box-sizing:border-box">
|
||||
<a routerLink="/dashboard" style="display:flex;align-items:center;gap:0.75rem;color:inherit;text-decoration:none">
|
||||
<span aria-hidden="true" style="display:inline-block;width:0.5rem;height:2.25rem;background:#fff"></span>
|
||||
<span style="font-weight:700;line-height:1.1">
|
||||
Rijksoverheid<br><span style="font-weight:400;font-size:0.9rem">BIG-register</span>
|
||||
<header class="utrecht-page-header bar">
|
||||
<div class="inner">
|
||||
<a routerLink="/dashboard" class="brand">
|
||||
<span aria-hidden="true" class="mark"></span>
|
||||
<span class="name">
|
||||
Rijksoverheid<br><span class="sub">BIG-register</span>
|
||||
</span>
|
||||
</a>
|
||||
<span style="margin-left:auto;font-weight:500">{{ subtitle() }}</span>
|
||||
<span class="portal">{{ subtitle() }}</span>
|
||||
</div>
|
||||
</header>
|
||||
`,
|
||||
|
||||
@@ -35,6 +35,7 @@ export class AsyncErrorDirective {
|
||||
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" /> }
|
||||
@@ -60,6 +61,7 @@ export class AsyncErrorDirective {
|
||||
[ngTemplateOutletContext]="{ $implicit: value() }" />
|
||||
}
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class AsyncComponent<T> {
|
||||
|
||||
@@ -6,13 +6,13 @@ import { Component, input } from '@angular/core';
|
||||
selector: 'app-form-field',
|
||||
template: `
|
||||
<div class="rhc-form-field utrecht-form-field" [class.utrecht-form-field--invalid]="!!error()">
|
||||
<label class="utrecht-form-label" [for]="fieldId()">{{ label() }}</label>
|
||||
<label class="utrecht-form-label" [id]="fieldId() + '-label'" [for]="fieldId()">{{ label() }}</label>
|
||||
@if (description()) {
|
||||
<div class="utrecht-form-field-description">{{ description() }}</div>
|
||||
<div class="utrecht-form-field-description" [id]="fieldId() + '-desc'">{{ description() }}</div>
|
||||
}
|
||||
<ng-content />
|
||||
@if (error()) {
|
||||
<div class="utrecht-form-field-error-message" role="alert">{{ error() }}</div>
|
||||
<div class="utrecht-form-field-error-message" [id]="fieldId() + '-error'" role="alert">{{ error() }}</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
|
||||
@@ -10,10 +10,23 @@ export interface RadioOption {
|
||||
form control so it works with ngModel just like the text-input atom. */
|
||||
@Component({
|
||||
selector: 'app-radio-group',
|
||||
styles: [`
|
||||
.rhc-radio-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--rhc-space-max-md);
|
||||
padding-block: var(--rhc-space-max-sm);
|
||||
}
|
||||
`],
|
||||
template: `
|
||||
<div class="utrecht-form-field-radio-group" role="radiogroup">
|
||||
<div
|
||||
class="utrecht-form-field-radio-group"
|
||||
role="radiogroup"
|
||||
[attr.aria-labelledby]="name() + '-label'"
|
||||
[attr.aria-invalid]="invalid() ? 'true' : null"
|
||||
[attr.aria-describedby]="invalid() ? name() + '-error' : null">
|
||||
@for (opt of options(); track opt.value) {
|
||||
<label class="utrecht-form-label utrecht-form-label--radio-button" style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0">
|
||||
<label class="utrecht-form-label utrecht-form-label--radio-button rhc-radio-option">
|
||||
<input
|
||||
class="utrecht-radio-button"
|
||||
[class.utrecht-radio-button--checked]="value === opt.value"
|
||||
@@ -34,6 +47,7 @@ export interface RadioOption {
|
||||
export class RadioGroupComponent implements ControlValueAccessor {
|
||||
options = input.required<RadioOption[]>();
|
||||
name = input.required<string>();
|
||||
invalid = input(false);
|
||||
|
||||
value = '';
|
||||
disabled = false;
|
||||
|
||||
@@ -6,15 +6,15 @@ import { Component, OnDestroy, OnInit, computed, input, signal } from '@angular/
|
||||
selector: 'app-skeleton',
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.sk{background:linear-gradient(90deg,#e8ebee 25%,#f3f5f6 37%,#e8ebee 63%);
|
||||
background-size:400% 100%;animation:sh 1.4s ease infinite;border-radius:4px;
|
||||
margin-block-end:0.6rem}
|
||||
.sk{background:linear-gradient(90deg,var(--rhc-color-cool-grey-200) 25%,var(--rhc-color-cool-grey-100) 37%,var(--rhc-color-cool-grey-200) 63%);
|
||||
background-size:400% 100%;animation:sh 1.4s ease infinite;border-radius:var(--rhc-border-radius-md);
|
||||
margin-block-end:var(--rhc-space-max-lg)}
|
||||
@keyframes sh{0%{background-position:100% 0}100%{background-position:0 0}}
|
||||
`],
|
||||
template: `
|
||||
@if (visible()) {
|
||||
@for (l of lines(); track $index) {
|
||||
<div class="sk" [style.width]="width()" [style.height]="height()"></div>
|
||||
<div class="sk" aria-hidden="true" [style.width]="width()" [style.height]="height()"></div>
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -6,10 +6,10 @@ import { Component, OnDestroy, OnInit, input, signal } from '@angular/core';
|
||||
selector: 'app-spinner',
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.sp{width:2rem;height:2rem;border-radius:50%;
|
||||
border:3px solid var(--rhc-color-grijs-300,#cad0d6);
|
||||
border-block-start-color:var(--rhc-color-lintblauw-700,#154273);
|
||||
animation:sp 0.8s linear infinite;margin:1.5rem auto}
|
||||
.sp{inline-size:var(--rhc-space-max-3xl);block-size:var(--rhc-space-max-3xl);border-radius:var(--rhc-border-radius-round);
|
||||
border:var(--rhc-space-max-xs) solid var(--rhc-color-cool-grey-300);
|
||||
border-block-start-color:var(--rhc-color-lintblauw-700);
|
||||
animation:sp 0.8s linear infinite;margin:var(--rhc-space-max-2xl) auto}
|
||||
@keyframes sp{to{transform:rotate(360deg)}}
|
||||
`],
|
||||
template: `
|
||||
|
||||
@@ -5,8 +5,9 @@ import { Component, input } from '@angular/core';
|
||||
This keeps the shared UI kernel free of any domain knowledge. */
|
||||
@Component({
|
||||
selector: 'app-status-badge',
|
||||
styles: [`.badge{display:inline-flex;align-items:center;gap:var(--rhc-space-max-md)}`],
|
||||
template: `
|
||||
<span style="display:inline-flex;align-items:center;gap:0.5rem">
|
||||
<span class="badge">
|
||||
<span class="rhc-dot-badge" [style.background-color]="color()" aria-hidden="true"></span>
|
||||
<span>{{ label() }}</span>
|
||||
</span>
|
||||
|
||||
46
src/app/shared/ui/stepper/stepper.component.ts
Normal file
46
src/app/shared/ui/stepper/stepper.component.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
|
||||
/** Molecule: a horizontal step indicator for a multi-step flow. Visual progress
|
||||
plus accessible semantics — an ordered list with `aria-current="step"` on the
|
||||
active step and a visually-hidden "Stap X van Y" summary. Domain-free. */
|
||||
@Component({
|
||||
selector: 'app-stepper',
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.sr-only{position:absolute;inline-size:1px;block-size:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
|
||||
.steps{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-xl);list-style:none;margin:0;padding:0}
|
||||
.step{display:flex;align-items:center;gap:var(--rhc-space-max-md);color:var(--rhc-color-foreground-subtle)}
|
||||
.num{
|
||||
display:grid;place-items:center;
|
||||
inline-size:var(--rhc-space-max-4xl);block-size:var(--rhc-space-max-4xl);
|
||||
border-radius:var(--rhc-border-radius-round);
|
||||
border:var(--rhc-space-max-xs) solid var(--rhc-color-cool-grey-400);
|
||||
font-weight:var(--rhc-text-font-weight-bold);
|
||||
}
|
||||
.step--done .num{background:var(--rhc-color-lintblauw-500);border-color:var(--rhc-color-lintblauw-500);color:var(--rhc-color-wit)}
|
||||
.step--current{color:var(--rhc-color-foreground-default)}
|
||||
.step--current .num{background:var(--rhc-color-lintblauw-700);border-color:var(--rhc-color-lintblauw-700);color:var(--rhc-color-wit)}
|
||||
.step--current .label{font-weight:var(--rhc-text-font-weight-semi-bold)}
|
||||
`],
|
||||
template: `
|
||||
<nav aria-label="Voortgang">
|
||||
<p class="sr-only">Stap {{ current() + 1 }} van {{ steps().length }}: {{ steps()[current()] }}</p>
|
||||
<ol class="steps">
|
||||
@for (label of steps(); track label; let i = $index) {
|
||||
<li
|
||||
class="step"
|
||||
[class.step--current]="i === current()"
|
||||
[class.step--done]="i < current()"
|
||||
[attr.aria-current]="i === current() ? 'step' : null">
|
||||
<span class="num" aria-hidden="true">{{ i + 1 }}</span>
|
||||
<span class="label">{{ label }}</span>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</nav>
|
||||
`,
|
||||
})
|
||||
export class StepperComponent {
|
||||
steps = input.required<string[]>();
|
||||
current = input.required<number>();
|
||||
}
|
||||
19
src/app/shared/ui/stepper/stepper.stories.ts
Normal file
19
src/app/shared/ui/stepper/stepper.stories.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { StepperComponent } from './stepper.component';
|
||||
|
||||
const meta: Meta<StepperComponent> = {
|
||||
title: 'Molecules/Stepper',
|
||||
component: StepperComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-stepper [steps]="steps" [current]="current" />`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<StepperComponent>;
|
||||
|
||||
const steps = ['Adres', 'Beroep', 'Controle'];
|
||||
|
||||
export const Eerste: Story = { args: { steps, current: 0 } };
|
||||
export const Midden: Story = { args: { steps, current: 1 } };
|
||||
export const Laatste: Story = { args: { steps, current: 2 } };
|
||||
@@ -10,6 +10,8 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
[class.utrecht-textbox--invalid]="invalid()"
|
||||
[type]="type()"
|
||||
[id]="inputId()"
|
||||
[attr.aria-invalid]="invalid() ? 'true' : null"
|
||||
[attr.aria-describedby]="invalid() && inputId() ? inputId() + '-error' : null"
|
||||
[placeholder]="placeholder()"
|
||||
[disabled]="disabled"
|
||||
[value]="value"
|
||||
|
||||
Reference in New Issue
Block a user