Full-width layout, page-shell template, httpResource async states, README
- Fix full-bleed header/footer (align-items:stretch + block hosts; centered content column via shared --app-content-max). - New templates/page-shell (back-link + heading + intro + content, narrow mode); all pages refactored to compose it. - Async state management with native httpResource + <app-async> wrapper that renders exactly one of loading/empty/error/loaded (impossible states unrepresentable); delayed spinner + skeleton atoms for slow/fast connections. - Scenario interceptor (?scenario=slow|loading|empty|error) to demo every state. - Storybook: spinner/skeleton/page-shell/async-states stories. - README rewritten as a guide (atomic design, reuse benefits, state handling). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
161
README.md
161
README.md
@@ -1,48 +1,131 @@
|
||||
# BIG-register Self Service Portal — Atomic Design POC
|
||||
# BIG-register Self-Service Portal — Atomic Design POC
|
||||
|
||||
An Angular POC showing how **atomic design** makes a frontend cheap to build and
|
||||
reuse. Domain: the **BIG-register** self-service portal (NL register of healthcare
|
||||
professionals). Styled with the **Rijkshuisstijl** via the NL Design System /
|
||||
Utrecht component CSS — no hand-written theme.
|
||||
A small Angular app that shows how **atomic design** makes a frontend cheap to build,
|
||||
reuse and extend. The domain is the **BIG-register** self-service portal (the Dutch
|
||||
register of healthcare professionals, run by CIBG). It looks like an NL Design System
|
||||
app, branded **Rijkshuisstijl**, and demonstrates a robust **async-state pattern** where
|
||||
the UI can never reach an inconsistent state.
|
||||
|
||||
## The atomic-design story (folder = layer)
|
||||
> Demo / POC — no real data, no real login. Free **Fira Sans** stands in for the
|
||||
> licensed Rijksoverheid font and a text wordmark for the logo.
|
||||
|
||||
```
|
||||
src/app/
|
||||
atoms/ button · text-input · heading · link · alert · status-badge
|
||||
molecules/ form-field (label+input+error) · data-row
|
||||
organisms/ site-header · site-footer · login-form · registration-summary
|
||||
registration-table · change-request-form
|
||||
templates/ page-layout (header + content + footer)
|
||||
pages/ login · dashboard · registration-detail
|
||||
core/ models · registration.service (HttpClient → public/mock/*.json)
|
||||
```
|
||||
---
|
||||
|
||||
**Reuse in action** — the same building blocks appear everywhere:
|
||||
- `page-layout` wraps all three pages.
|
||||
- `site-header` + `site-footer` render on every page.
|
||||
- `form-field` + `text-input` + `button` are shared by the login form *and* the
|
||||
change-request form.
|
||||
- `status-badge` shows on both the dashboard and the detail page.
|
||||
|
||||
Adding a new page is just composing existing organisms inside the layout — see
|
||||
`pages/` for how little code each page is.
|
||||
|
||||
## Styling / theming
|
||||
|
||||
`@rijkshuisstijl-community/design-tokens` + `@rijkshuisstijl-community/components-css`
|
||||
provide Rijkshuisstijl-themed Utrecht components as CSS. The theme is applied by the
|
||||
`rhc-theme lintblauw` classes on `<body>` (see `src/index.html`). Atoms are thin
|
||||
Angular wrappers that apply these CSS classes — so the design system does the visual
|
||||
work and we only own the component API.
|
||||
|
||||
> POC shortcuts: free **Fira Sans** stands in for the licensed Rijksoverheid font, a
|
||||
> text wordmark stands in for the logo, login is mocked, and data is static JSON.
|
||||
|
||||
## Run
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm start # ng serve → http://localhost:4200 (login → dashboard → detail)
|
||||
npm start # app → http://localhost:4200
|
||||
npm run storybook # component library, organized by atomic layer
|
||||
```
|
||||
|
||||
Flow: **Login → Dashboard → Mijn gegevens (wijziging) → Herregistratie**.
|
||||
|
||||
### See every data state (scenario toggle)
|
||||
|
||||
Append `?scenario=` to any data page (e.g. `/dashboard`) to force an async state:
|
||||
|
||||
| URL | What you see |
|
||||
|-----|--------------|
|
||||
| `/dashboard` | real data (fast) |
|
||||
| `/dashboard?scenario=slow` | skeletons for ~2.5s, then data |
|
||||
| `/dashboard?scenario=loading` | the loading state, held open |
|
||||
| `/dashboard?scenario=empty` | "geen gegevens" empty state |
|
||||
| `/dashboard?scenario=error` | error message + **Opnieuw proberen** (retry) |
|
||||
|
||||
---
|
||||
|
||||
## How atomic design works here (folder = layer)
|
||||
|
||||
Atomic design organizes UI into five layers, each built from the one below. In this repo
|
||||
the folder structure *is* the hierarchy (`src/app/`):
|
||||
|
||||
| Layer | What it is | Examples here |
|
||||
|-------|-----------|---------------|
|
||||
| **atoms/** | smallest building blocks; wrap one design-system element | `button`, `text-input`, `heading`, `link`, `alert`, `status-badge`, `spinner`, `skeleton` |
|
||||
| **molecules/** | a few atoms combined into a unit | `form-field` (label + input + error), `data-row`, `async` (state wrapper) |
|
||||
| **organisms/** | larger, self-contained sections | `site-header`, `site-footer`, `login-form`, `registration-summary`, `registration-table`, `change-request-form` |
|
||||
| **templates/** | page skeletons that define layout; content is projected in | `page-layout` (header/content/footer chrome), `page-shell` (back-link + heading + intro + content) |
|
||||
| **pages/** | a template filled with real data | `login`, `dashboard`, `registration-detail`, `herregistratie` |
|
||||
|
||||
Each atom is a thin Angular standalone component that applies the Utrecht/Rijkshuisstijl
|
||||
CSS classes — so the design system does the visual work and we only own a small, typed
|
||||
component API.
|
||||
|
||||
---
|
||||
|
||||
## Where you actually notice the benefit
|
||||
|
||||
**1. Reuse — the same blocks appear everywhere.**
|
||||
|
||||
| Component | Appears in |
|
||||
|-----------|-----------|
|
||||
| `button` | login, change-request, herregistratie, async retry, Storybook |
|
||||
| `form-field` + `text-input` | login form *and* change-request *and* herregistratie |
|
||||
| `status-badge` | dashboard summary, detail summary |
|
||||
| `page-shell` / `page-layout` | all four pages |
|
||||
| `site-header` / `site-footer` | every page |
|
||||
| `async` + `skeleton` | dashboard, detail |
|
||||
|
||||
Change a component once and every screen that uses it updates.
|
||||
|
||||
**2. A whole new page = composition, no new components.**
|
||||
`pages/herregistratie/herregistratie.page.ts` is a complete new flow assembled entirely
|
||||
from existing atoms/molecules/templates — zero new building blocks. That's the payoff:
|
||||
new screens cost almost nothing.
|
||||
|
||||
**3. Templates remove per-page boilerplate.**
|
||||
Every page used to repeat its own back-link + heading + intro markup. `page-shell`
|
||||
captures that once; pages now read like `<app-page-shell heading="…" backLink="…">…`.
|
||||
|
||||
**4. Theming is one import.**
|
||||
The look comes from `@rijkshuisstijl-community/design-tokens`. `src/styles.scss` imports
|
||||
the `lintblauw` palette and applies `rhc-theme lintblauw` on `<body>`. Swap the palette
|
||||
import to re-theme the whole app — no component changes.
|
||||
|
||||
---
|
||||
|
||||
## State management (no impossible states)
|
||||
|
||||
Data fetching uses Angular's native, signal-based **`httpResource`** (no NgRx,
|
||||
no extra dependency). `core/registration.service.ts` exposes resources that carry
|
||||
`status()`, `value()`, `error()`, `hasValue()` and `reload()` as signals.
|
||||
|
||||
The molecule **`<app-async>`** turns those signals into UI. It renders **exactly one** of
|
||||
four slots, chosen by a single `computed` — so loading, empty, error and loaded are
|
||||
mutually exclusive *by construction*. You cannot render data and an error at the same
|
||||
time, or show stale content during a hard failure: those states are unrepresentable.
|
||||
|
||||
```html
|
||||
<app-async [resource]="reg" [isEmpty]="regEmpty">
|
||||
<ng-template appAsyncLoaded let-r> <app-registration-summary [reg]="r" /> </ng-template>
|
||||
<ng-template appAsyncLoading> <app-skeleton [count]="6" /> </ng-template>
|
||||
<!-- appAsyncEmpty / appAsyncError are optional → sensible defaults -->
|
||||
</app-async>
|
||||
```
|
||||
|
||||
- **Loaded** — your content, with the value.
|
||||
- **Loading** — your skeleton, or a default **delayed spinner** (only appears after
|
||||
~250ms, so fast connections never flash a spinner; slow ones get feedback). Skeletons
|
||||
are also delay-gated. → *handles slow vs fast connections.*
|
||||
- **Empty** — your message, or a default "Geen gegevens gevonden" (driven by an
|
||||
`isEmpty` predicate).
|
||||
- **Error** — your template, or a default alert + a **retry** button that calls
|
||||
`resource.reload()`.
|
||||
|
||||
Because each data-fetching page wraps its content in `<app-async>`, correct
|
||||
loading/empty/error handling is automatic and consistent across the app.
|
||||
|
||||
---
|
||||
|
||||
## Tech notes
|
||||
|
||||
- Angular 22 (standalone components, signals, `httpResource`, control flow `@if/@for`).
|
||||
- Styling: `@rijkshuisstijl-community/{design-tokens,components-css}` (Utrecht + RHC CSS,
|
||||
pre-themed Rijkshuisstijl) — imported in `src/styles.scss`, no hand-written theme.
|
||||
- Mock data: JSON in `public/mock/`, timing/outcome shaped by `core/scenario.interceptor.ts`.
|
||||
- `.npmrc` sets `legacy-peer-deps=true` because `@storybook/angular`'s peer range lags
|
||||
Angular 22; the builder runs fine (build verified).
|
||||
|
||||
### Deliberately out of scope (POC)
|
||||
Real auth/DigiD, real backend, i18n, NgRx, licensed Rijkshuisstijl fonts/logo.
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"packageManager": "npm"
|
||||
"packageManager": "npm",
|
||||
"analytics": false
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
|
||||
1448
documentation.json
1448
documentation.json
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,11 @@
|
||||
import { ApplicationConfig, LOCALE_ID, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { registerLocaleData } from '@angular/common';
|
||||
import localeNl from '@angular/common/locales/nl';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { scenarioInterceptor } from './core/scenario.interceptor';
|
||||
|
||||
registerLocaleData(localeNl);
|
||||
|
||||
@@ -12,7 +13,7 @@ export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(routes),
|
||||
provideHttpClient(),
|
||||
provideHttpClient(withInterceptors([scenarioInterceptor])),
|
||||
{ provide: LOCALE_ID, useValue: 'nl' },
|
||||
]
|
||||
};
|
||||
|
||||
33
src/app/atoms/skeleton/skeleton.component.ts
Normal file
33
src/app/atoms/skeleton/skeleton.component.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Component, OnDestroy, OnInit, computed, input, signal } from '@angular/core';
|
||||
|
||||
/** Atom: skeleton placeholder (grey shimmer). Delay-gated so it never flashes
|
||||
on fast responses. Render `count` lines shaped roughly like the content. */
|
||||
@Component({
|
||||
selector: 'app-skeleton',
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.sk{background:linear-gradient(90deg,#e8ebee 25%,#f3f5f6 37%,#e8ebee 63%);
|
||||
background-size:400% 100%;animation:sh 1.4s ease infinite;border-radius:4px;
|
||||
margin-block-end:0.6rem}
|
||||
@keyframes sh{0%{background-position:100% 0}100%{background-position:0 0}}
|
||||
`],
|
||||
template: `
|
||||
@if (visible()) {
|
||||
@for (l of lines(); track $index) {
|
||||
<div class="sk" [style.width]="width()" [style.height]="height()"></div>
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class SkeletonComponent implements OnInit, OnDestroy {
|
||||
width = input('100%');
|
||||
height = input('1rem');
|
||||
count = input(1);
|
||||
delay = input(150);
|
||||
protected visible = signal(false);
|
||||
protected lines = computed(() => Array(this.count()).fill(0));
|
||||
private timer?: ReturnType<typeof setTimeout>;
|
||||
|
||||
ngOnInit() { this.timer = setTimeout(() => this.visible.set(true), this.delay()); }
|
||||
ngOnDestroy() { clearTimeout(this.timer); }
|
||||
}
|
||||
13
src/app/atoms/skeleton/skeleton.stories.ts
Normal file
13
src/app/atoms/skeleton/skeleton.stories.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { SkeletonComponent } from './skeleton.component';
|
||||
|
||||
const meta: Meta<SkeletonComponent> = {
|
||||
title: 'Atoms/Skeleton',
|
||||
component: SkeletonComponent,
|
||||
args: { delay: 0 },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<SkeletonComponent>;
|
||||
|
||||
export const SingleLine: Story = { args: { width: '60%', height: '1rem' } };
|
||||
export const CardPlaceholder: Story = { args: { height: '2.5rem', count: 6 } };
|
||||
28
src/app/atoms/spinner/spinner.component.ts
Normal file
28
src/app/atoms/spinner/spinner.component.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Component, OnDestroy, OnInit, input, signal } from '@angular/core';
|
||||
|
||||
/** Atom: spinner that only appears after `delay` ms — fast responses never
|
||||
flash a spinner, slow ones get feedback. */
|
||||
@Component({
|
||||
selector: 'app-spinner',
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.sp{width:2rem;height:2rem;border-radius:50%;
|
||||
border:3px solid var(--rhc-color-grijs-300,#cad0d6);
|
||||
border-block-start-color:var(--rhc-color-lintblauw-700,#154273);
|
||||
animation:sp 0.8s linear infinite;margin:1.5rem auto}
|
||||
@keyframes sp{to{transform:rotate(360deg)}}
|
||||
`],
|
||||
template: `
|
||||
@if (visible()) {
|
||||
<div class="sp" role="status" aria-label="Bezig met laden"></div>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class SpinnerComponent implements OnInit, OnDestroy {
|
||||
delay = input(250);
|
||||
protected visible = signal(false);
|
||||
private timer?: ReturnType<typeof setTimeout>;
|
||||
|
||||
ngOnInit() { this.timer = setTimeout(() => this.visible.set(true), this.delay()); }
|
||||
ngOnDestroy() { clearTimeout(this.timer); }
|
||||
}
|
||||
12
src/app/atoms/spinner/spinner.stories.ts
Normal file
12
src/app/atoms/spinner/spinner.stories.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { SpinnerComponent } from './spinner.component';
|
||||
|
||||
const meta: Meta<SpinnerComponent> = {
|
||||
title: 'Atoms/Spinner',
|
||||
component: SpinnerComponent,
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<SpinnerComponent>;
|
||||
|
||||
// delay 0 so it shows immediately in the story
|
||||
export const Default: Story = { args: { delay: 0 } };
|
||||
@@ -1,17 +1,20 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { httpResource } from '@angular/common/http';
|
||||
import { Registration, Aantekening } from './models';
|
||||
|
||||
/**
|
||||
* Exposes signal-based resources (Angular's httpResource). Each returns a
|
||||
* Resource with status()/value()/error()/reload() — consumed by <app-async>,
|
||||
* which turns those signals into the loading/empty/error/loaded UI.
|
||||
* Call these from a component field initializer (injection context required).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RegistrationService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
getRegistration(): Observable<Registration> {
|
||||
return this.http.get<Registration>('mock/registration.json');
|
||||
registrationResource() {
|
||||
return httpResource<Registration>(() => 'mock/registration.json');
|
||||
}
|
||||
|
||||
getAantekeningen(): Observable<Aantekening[]> {
|
||||
return this.http.get<Aantekening[]>('mock/notes.json');
|
||||
aantekeningenResource() {
|
||||
return httpResource<Aantekening[]>(() => 'mock/notes.json', { defaultValue: [] });
|
||||
}
|
||||
}
|
||||
|
||||
29
src/app/core/scenario.interceptor.ts
Normal file
29
src/app/core/scenario.interceptor.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { HttpErrorResponse, HttpInterceptorFn, HttpResponse } from '@angular/common/http';
|
||||
import { of, switchMap, throwError, timer } from 'rxjs';
|
||||
import { delay } from 'rxjs/operators';
|
||||
import { currentScenario } from './scenario';
|
||||
|
||||
/**
|
||||
* Demo-only: rewrites the timing/outcome of mock data requests based on
|
||||
* ?scenario= so loading / empty / error states can be shown on demand.
|
||||
* Real requests are untouched.
|
||||
*/
|
||||
export const scenarioInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
if (!req.url.includes('mock/')) return next(req);
|
||||
|
||||
switch (currentScenario()) {
|
||||
case 'slow':
|
||||
return next(req).pipe(delay(2500));
|
||||
case 'loading':
|
||||
return next(req).pipe(delay(600_000)); // effectively never resolves
|
||||
case 'empty':
|
||||
return of(new HttpResponse({ status: 200, body: [] })).pipe(delay(400));
|
||||
case 'error':
|
||||
return timer(400).pipe(
|
||||
switchMap(() => throwError(() =>
|
||||
new HttpErrorResponse({ status: 500, statusText: 'Demo-fout', url: req.url }))),
|
||||
);
|
||||
default:
|
||||
return next(req);
|
||||
}
|
||||
};
|
||||
9
src/app/core/scenario.ts
Normal file
9
src/app/core/scenario.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type Scenario = 'default' | 'slow' | 'loading' | 'empty' | 'error';
|
||||
|
||||
const VALID: Scenario[] = ['default', 'slow', 'loading', 'empty', 'error'];
|
||||
|
||||
/** Reads ?scenario= from the URL so a demo can force each async state. */
|
||||
export function currentScenario(): Scenario {
|
||||
const s = new URLSearchParams(window.location.search).get('scenario') as Scenario | null;
|
||||
return s && VALID.includes(s) ? s : 'default';
|
||||
}
|
||||
99
src/app/molecules/async/async.component.ts
Normal file
99
src/app/molecules/async/async.component.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Component, Directive, TemplateRef, computed, contentChild, input } from '@angular/core';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import type { Resource } from '@angular/core';
|
||||
import { SpinnerComponent } from '../../atoms/spinner/spinner.component';
|
||||
import { AlertComponent } from '../../atoms/alert/alert.component';
|
||||
import { ButtonComponent } from '../../atoms/button/button.component';
|
||||
|
||||
/* Slot markers. Put on <ng-template> children of <app-async>. */
|
||||
@Directive({ selector: '[appAsyncLoaded]' })
|
||||
export class AsyncLoadedDirective {
|
||||
constructor(public tpl: TemplateRef<{ $implicit: unknown }>) {}
|
||||
}
|
||||
@Directive({ selector: '[appAsyncLoading]' })
|
||||
export class AsyncLoadingDirective {
|
||||
constructor(public tpl: TemplateRef<unknown>) {}
|
||||
}
|
||||
@Directive({ selector: '[appAsyncEmpty]' })
|
||||
export class AsyncEmptyDirective {
|
||||
constructor(public tpl: TemplateRef<unknown>) {}
|
||||
}
|
||||
@Directive({ selector: '[appAsyncError]' })
|
||||
export class AsyncErrorDirective {
|
||||
constructor(public tpl: TemplateRef<{ $implicit: Error | undefined; retry: () => void }>) {}
|
||||
}
|
||||
|
||||
type State = 'loading' | 'error' | 'empty' | 'loaded';
|
||||
|
||||
/**
|
||||
* Renders exactly ONE of loading / empty / error / loaded for a signal-based
|
||||
* resource (e.g. httpResource). The states are mutually exclusive by
|
||||
* construction, so the UI can never show two at once ("impossible states").
|
||||
* Unprovided slots fall back to sensible defaults.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-async',
|
||||
imports: [NgTemplateOutlet, SpinnerComponent, AlertComponent, ButtonComponent],
|
||||
template: `
|
||||
@switch (state()) {
|
||||
@case ('loading') {
|
||||
@if (loadingTpl()) { <ng-container [ngTemplateOutlet]="loadingTpl()!.tpl" /> }
|
||||
@else { <app-spinner /> }
|
||||
}
|
||||
@case ('error') {
|
||||
@if (errorTpl()) {
|
||||
<ng-container [ngTemplateOutlet]="errorTpl()!.tpl"
|
||||
[ngTemplateOutletContext]="{ $implicit: resource().error(), retry: retry }" />
|
||||
} @else {
|
||||
<app-alert type="error">Er ging iets mis bij het laden van de gegevens.</app-alert>
|
||||
<div style="margin-top:1rem">
|
||||
<app-button variant="secondary" (click)="retry()">Opnieuw proberen</app-button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@case ('empty') {
|
||||
@if (emptyTpl()) { <ng-container [ngTemplateOutlet]="emptyTpl()!.tpl" /> }
|
||||
@else { <p class="rhc-paragraph">Geen gegevens gevonden.</p> }
|
||||
}
|
||||
@case ('loaded') {
|
||||
<ng-container [ngTemplateOutlet]="loadedTpl().tpl"
|
||||
[ngTemplateOutletContext]="{ $implicit: value() }" />
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class AsyncComponent<T> {
|
||||
resource = input.required<Resource<T>>();
|
||||
isEmpty = input<(v: T) => boolean>(() => false);
|
||||
|
||||
loadedTpl = contentChild.required(AsyncLoadedDirective);
|
||||
loadingTpl = contentChild(AsyncLoadingDirective);
|
||||
emptyTpl = contentChild(AsyncEmptyDirective);
|
||||
errorTpl = contentChild(AsyncErrorDirective);
|
||||
|
||||
protected value = computed(() => {
|
||||
const r = this.resource();
|
||||
return r.hasValue() ? r.value() : undefined;
|
||||
});
|
||||
|
||||
protected state = computed<State>(() => {
|
||||
const r = this.resource();
|
||||
if (r.status() === 'error') return 'error';
|
||||
if (r.status() === 'loading') return 'loading';
|
||||
if (r.hasValue()) return this.isEmpty()(r.value()) ? 'empty' : 'loaded';
|
||||
return 'loading';
|
||||
});
|
||||
|
||||
retry = () => {
|
||||
const r = this.resource();
|
||||
if ('reload' in r && typeof (r as { reload?: unknown }).reload === 'function') {
|
||||
(r as { reload: () => void }).reload();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Convenience: import this array to get the wrapper + all slot directives. */
|
||||
export const ASYNC = [
|
||||
AsyncComponent, AsyncLoadedDirective, AsyncLoadingDirective,
|
||||
AsyncEmptyDirective, AsyncErrorDirective,
|
||||
] as const;
|
||||
43
src/app/molecules/async/async.stories.ts
Normal file
43
src/app/molecules/async/async.stories.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { moduleMetadata } from '@storybook/angular';
|
||||
import type { Resource } from '@angular/core';
|
||||
import { ASYNC } from './async.component';
|
||||
import { SkeletonComponent } from '../../atoms/skeleton/skeleton.component';
|
||||
|
||||
/** Minimal fake of a signal Resource so the wrapper can be driven through every
|
||||
state in isolation (no HTTP). */
|
||||
function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T> {
|
||||
return {
|
||||
value: () => value as T,
|
||||
status: () => status,
|
||||
error: () => error,
|
||||
hasValue: () => value !== undefined,
|
||||
reload: () => {},
|
||||
} as unknown as Resource<T>;
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Molecules/Async States',
|
||||
decorators: [moduleMetadata({ imports: [...ASYNC, SkeletonComponent] })],
|
||||
render: (args) => ({
|
||||
// isEmpty is a function — Storybook strips function args, so set it here.
|
||||
props: { ...args, isEmpty: (v: string[]) => !v || v.length === 0 },
|
||||
template: `
|
||||
<app-async [resource]="resource" [isEmpty]="isEmpty">
|
||||
<ng-template appAsyncLoaded let-items>
|
||||
<ul class="rhc-unordered-list">
|
||||
@for (i of items; track i) { <li>{{ i }}</li> }
|
||||
</ul>
|
||||
</ng-template>
|
||||
<ng-template appAsyncLoading><app-skeleton [count]="3" height="1.5rem" [delay]="0" /></ng-template>
|
||||
<ng-template appAsyncEmpty><p class="rhc-paragraph">Geen items gevonden.</p></ng-template>
|
||||
</app-async>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj;
|
||||
|
||||
export const Loaded: Story = { args: { resource: fakeResource('resolved', ['Huisartsgeneeskunde', 'Spoedeisende hulp']) } };
|
||||
export const Loading: Story = { args: { resource: fakeResource('loading') } };
|
||||
export const Empty: Story = { args: { resource: fakeResource('resolved', [] as string[]) } };
|
||||
export const ErrorState: Story = { args: { resource: fakeResource('error', undefined, new Error('Demo')) } };
|
||||
@@ -3,17 +3,13 @@ import { FormsModule } from '@angular/forms';
|
||||
import { FormFieldComponent } from '../../molecules/form-field/form-field.component';
|
||||
import { TextInputComponent } from '../../atoms/text-input/text-input.component';
|
||||
import { ButtonComponent } from '../../atoms/button/button.component';
|
||||
import { HeadingComponent } from '../../atoms/heading/heading.component';
|
||||
|
||||
/** Organism: DigiD-style mock login. No real auth — just composes atoms/molecules. */
|
||||
@Component({
|
||||
selector: 'app-login-form',
|
||||
imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent, HeadingComponent],
|
||||
imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent],
|
||||
template: `
|
||||
<form (ngSubmit)="submit.emit()" style="max-width:24rem">
|
||||
<app-heading [level]="1">Inloggen</app-heading>
|
||||
<p class="rhc-paragraph">Log in op uw persoonlijke BIG-register omgeving.</p>
|
||||
|
||||
<form (ngSubmit)="submit.emit()">
|
||||
<app-form-field label="BSN" fieldId="bsn" description="9 cijfers (demo: vul iets in)">
|
||||
<app-text-input inputId="bsn" [(ngModel)]="bsn" name="bsn" placeholder="123456789" />
|
||||
</app-form-field>
|
||||
|
||||
@@ -3,9 +3,10 @@ import { Component } from '@angular/core';
|
||||
/** Organism: site footer. */
|
||||
@Component({
|
||||
selector: 'app-site-footer',
|
||||
styles: [':host{display:block}'],
|
||||
template: `
|
||||
<footer class="utrecht-page-footer" style="background:var(--rhc-color-lintblauw-900,#01689b);color:#fff;margin-top:3rem">
|
||||
<div style="max-width:64rem;margin:0 auto;padding:1.5rem;display:flex;gap:2rem;flex-wrap:wrap">
|
||||
<footer class="utrecht-page-footer" style="background:var(--rhc-color-lintblauw-900,#01689b);color:#fff;margin-top:3rem;width:100%">
|
||||
<div style="max-width:var(--app-content-max);margin:0 auto;padding:1.5rem;display:flex;gap:2rem;flex-wrap:wrap;box-sizing:border-box">
|
||||
<span>BIG-register</span>
|
||||
<span>CIBG — Ministerie van Volksgezondheid, Welzijn en Sport</span>
|
||||
<span style="margin-left:auto;opacity:0.85">Demo / POC — geen echte gegevens</span>
|
||||
|
||||
@@ -6,9 +6,12 @@ import { RouterLink } from '@angular/router';
|
||||
@Component({
|
||||
selector: 'app-site-header',
|
||||
imports: [RouterLink],
|
||||
// :host display:block so the full-bleed bar fills the flex column (custom
|
||||
// elements default to display:inline, which collapsed the bar to its content).
|
||||
styles: [':host{display:block}'],
|
||||
template: `
|
||||
<header class="utrecht-page-header" style="background:var(--rhc-color-lintblauw-700,#154273);color:#fff">
|
||||
<div style="display:flex;align-items:center;gap:1rem;max-width:64rem;margin:0 auto;padding:1rem 1.5rem">
|
||||
<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">
|
||||
|
||||
@@ -1,28 +1,45 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { PageLayoutComponent } from '../../templates/page-layout/page-layout.component';
|
||||
import { PageShellComponent } from '../../templates/page-shell/page-shell.component';
|
||||
import { HeadingComponent } from '../../atoms/heading/heading.component';
|
||||
import { LinkComponent } from '../../atoms/link/link.component';
|
||||
import { SkeletonComponent } from '../../atoms/skeleton/skeleton.component';
|
||||
import { ASYNC } from '../../molecules/async/async.component';
|
||||
import { RegistrationSummaryComponent } from '../../organisms/registration-summary/registration-summary.component';
|
||||
import { RegistrationTableComponent } from '../../organisms/registration-table/registration-table.component';
|
||||
import { RegistrationService } from '../../core/registration.service';
|
||||
import { Aantekening } from '../../core/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard-page',
|
||||
imports: [AsyncPipe, PageLayoutComponent, HeadingComponent, LinkComponent, RegistrationSummaryComponent, RegistrationTableComponent],
|
||||
imports: [
|
||||
PageShellComponent, HeadingComponent, LinkComponent, SkeletonComponent, ...ASYNC,
|
||||
RegistrationSummaryComponent, RegistrationTableComponent,
|
||||
],
|
||||
template: `
|
||||
<app-page-layout>
|
||||
<app-heading [level]="1">Mijn BIG-registratie</app-heading>
|
||||
|
||||
@if (reg$ | async; as reg) {
|
||||
<app-registration-summary [reg]="reg" />
|
||||
}
|
||||
<app-page-shell heading="Mijn BIG-registratie">
|
||||
<!-- Summary: loading -> skeleton, error -> retry, else the card -->
|
||||
<app-async [resource]="reg" [isEmpty]="regEmpty">
|
||||
<ng-template appAsyncLoaded let-r>
|
||||
<app-registration-summary [reg]="$any(r)" />
|
||||
</ng-template>
|
||||
<ng-template appAsyncLoading>
|
||||
<app-skeleton height="2.5rem" [count]="6" />
|
||||
</ng-template>
|
||||
</app-async>
|
||||
|
||||
<div style="margin-top:2rem">
|
||||
<app-heading [level]="2">Specialismen en aantekeningen</app-heading>
|
||||
@if (notes$ | async; as notes) {
|
||||
<app-registration-table [rows]="notes" />
|
||||
}
|
||||
<app-async [resource]="notes" [isEmpty]="notesEmpty">
|
||||
<ng-template appAsyncLoaded let-r>
|
||||
<app-registration-table [rows]="$any(r)" />
|
||||
</ng-template>
|
||||
<ng-template appAsyncLoading>
|
||||
<app-skeleton height="2.5rem" [count]="3" />
|
||||
</ng-template>
|
||||
<ng-template appAsyncEmpty>
|
||||
<p class="rhc-paragraph">U heeft nog geen specialismen of aantekeningen.</p>
|
||||
</ng-template>
|
||||
</app-async>
|
||||
</div>
|
||||
|
||||
<p style="margin-top:2rem">
|
||||
@@ -31,11 +48,13 @@ import { RegistrationService } from '../../core/registration.service';
|
||||
<p>
|
||||
<app-link to="/herregistratie">Herregistratie aanvragen →</app-link>
|
||||
</p>
|
||||
</app-page-layout>
|
||||
</app-page-shell>
|
||||
`,
|
||||
})
|
||||
export class DashboardPage {
|
||||
private svc = inject(RegistrationService);
|
||||
reg$ = this.svc.getRegistration();
|
||||
notes$ = this.svc.getAantekeningen();
|
||||
reg = this.svc.registrationResource();
|
||||
notes = this.svc.aantekeningenResource();
|
||||
notesEmpty = (v: Aantekening[]) => v.length === 0;
|
||||
regEmpty = (v: unknown) => !v || Object.keys(v).length === 0;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { PageLayoutComponent } from '../../templates/page-layout/page-layout.component';
|
||||
import { HeadingComponent } from '../../atoms/heading/heading.component';
|
||||
import { PageShellComponent } from '../../templates/page-shell/page-shell.component';
|
||||
import { AlertComponent } from '../../atoms/alert/alert.component';
|
||||
import { ButtonComponent } from '../../atoms/button/button.component';
|
||||
import { LinkComponent } from '../../atoms/link/link.component';
|
||||
import { FormFieldComponent } from '../../molecules/form-field/form-field.component';
|
||||
import { TextInputComponent } from '../../atoms/text-input/text-input.component';
|
||||
|
||||
@@ -13,14 +11,11 @@ import { TextInputComponent } from '../../atoms/text-input/text-input.component'
|
||||
@Component({
|
||||
selector: 'app-herregistratie-page',
|
||||
imports: [
|
||||
FormsModule, PageLayoutComponent, HeadingComponent, AlertComponent,
|
||||
ButtonComponent, LinkComponent, FormFieldComponent, TextInputComponent,
|
||||
FormsModule, PageShellComponent, AlertComponent,
|
||||
ButtonComponent, FormFieldComponent, TextInputComponent,
|
||||
],
|
||||
template: `
|
||||
<app-page-layout>
|
||||
<p><app-link to="/dashboard">← Terug naar overzicht</app-link></p>
|
||||
<app-heading [level]="1">Herregistratie aanvragen</app-heading>
|
||||
|
||||
<app-page-shell heading="Herregistratie aanvragen" backLink="/dashboard">
|
||||
<app-alert type="info">
|
||||
Uw huidige registratie verloopt op 1 september 2027. Vraag tijdig herregistratie aan.
|
||||
</app-alert>
|
||||
@@ -42,7 +37,7 @@ import { TextInputComponent } from '../../atoms/text-input/text-input.component'
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</app-page-layout>
|
||||
</app-page-shell>
|
||||
`,
|
||||
})
|
||||
export class HerregistratiePage {
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { PageLayoutComponent } from '../../templates/page-layout/page-layout.component';
|
||||
import { PageShellComponent } from '../../templates/page-shell/page-shell.component';
|
||||
import { LoginFormComponent } from '../../organisms/login-form/login-form.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login-page',
|
||||
imports: [PageLayoutComponent, LoginFormComponent],
|
||||
imports: [PageShellComponent, LoginFormComponent],
|
||||
template: `
|
||||
<app-page-layout>
|
||||
<app-page-shell heading="Inloggen" width="narrow"
|
||||
intro="Log in op uw persoonlijke BIG-register omgeving.">
|
||||
<app-login-form (submit)="login()" />
|
||||
</app-page-layout>
|
||||
</app-page-shell>
|
||||
`,
|
||||
})
|
||||
export class LoginPage {
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { PageLayoutComponent } from '../../templates/page-layout/page-layout.component';
|
||||
import { HeadingComponent } from '../../atoms/heading/heading.component';
|
||||
import { LinkComponent } from '../../atoms/link/link.component';
|
||||
import { PageShellComponent } from '../../templates/page-shell/page-shell.component';
|
||||
import { AlertComponent } from '../../atoms/alert/alert.component';
|
||||
import { SkeletonComponent } from '../../atoms/skeleton/skeleton.component';
|
||||
import { ASYNC } from '../../molecules/async/async.component';
|
||||
import { RegistrationSummaryComponent } from '../../organisms/registration-summary/registration-summary.component';
|
||||
import { ChangeRequestFormComponent } from '../../organisms/change-request-form/change-request-form.component';
|
||||
import { RegistrationService } from '../../core/registration.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-registration-detail-page',
|
||||
imports: [AsyncPipe, PageLayoutComponent, HeadingComponent, LinkComponent, AlertComponent, RegistrationSummaryComponent, ChangeRequestFormComponent],
|
||||
imports: [
|
||||
PageShellComponent, AlertComponent, SkeletonComponent, ...ASYNC,
|
||||
RegistrationSummaryComponent, ChangeRequestFormComponent,
|
||||
],
|
||||
template: `
|
||||
<app-page-layout>
|
||||
<p><app-link to="/dashboard">← Terug naar overzicht</app-link></p>
|
||||
<app-heading [level]="1">Mijn gegevens</app-heading>
|
||||
|
||||
@if (reg$ | async; as reg) {
|
||||
<app-registration-summary [reg]="reg" />
|
||||
}
|
||||
<app-page-shell heading="Mijn gegevens" backLink="/dashboard">
|
||||
<app-async [resource]="reg" [isEmpty]="regEmpty">
|
||||
<ng-template appAsyncLoaded let-r>
|
||||
<app-registration-summary [reg]="$any(r)" />
|
||||
</ng-template>
|
||||
<ng-template appAsyncLoading>
|
||||
<app-skeleton height="2.5rem" [count]="6" />
|
||||
</ng-template>
|
||||
</app-async>
|
||||
|
||||
<div style="margin-top:2rem">
|
||||
@if (submitted()) {
|
||||
@@ -27,11 +31,12 @@ import { RegistrationService } from '../../core/registration.service';
|
||||
<app-change-request-form (submitted)="submitted.set(true)" />
|
||||
}
|
||||
</div>
|
||||
</app-page-layout>
|
||||
</app-page-shell>
|
||||
`,
|
||||
})
|
||||
export class RegistrationDetailPage {
|
||||
private svc = inject(RegistrationService);
|
||||
reg$ = this.svc.getRegistration();
|
||||
reg = this.svc.registrationResource();
|
||||
regEmpty = (v: unknown) => !v || Object.keys(v).length === 0;
|
||||
submitted = signal(false);
|
||||
}
|
||||
|
||||
@@ -2,16 +2,23 @@ import { Component } from '@angular/core';
|
||||
import { SiteHeaderComponent } from '../../organisms/site-header/site-header.component';
|
||||
import { SiteFooterComponent } from '../../organisms/site-footer/site-footer.component';
|
||||
|
||||
/** Template: header + centered content + footer. Wraps every page. */
|
||||
/** Template: full-bleed header + footer with a centered content column. Wraps
|
||||
every page. The colored bars span the viewport; content lines up via the
|
||||
shared --app-content-max width. */
|
||||
@Component({
|
||||
selector: 'app-page-layout',
|
||||
imports: [SiteHeaderComponent, SiteFooterComponent],
|
||||
styles: [':host{display:block}'],
|
||||
template: `
|
||||
<a href="#main" class="rhc-skip-link" style="position:absolute;left:-999px">Naar de inhoud</a>
|
||||
<div class="utrecht-page-layout" style="display:flex;flex-direction:column;min-height:100vh">
|
||||
<!-- align-items:stretch overrides the design-system flex-start so the
|
||||
full-bleed header/footer bars fill the viewport width. -->
|
||||
<div class="utrecht-page-layout utrecht-page-layout--stretch" style="--app-content-max:64rem;min-height:100vh;align-items:stretch">
|
||||
<app-site-header />
|
||||
<main id="main" class="utrecht-page-content" style="flex:1;max-width:64rem;width:100%;margin:0 auto;padding:2rem 1.5rem;box-sizing:border-box">
|
||||
<ng-content />
|
||||
<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">
|
||||
<ng-content />
|
||||
</div>
|
||||
</main>
|
||||
<app-site-footer />
|
||||
</div>
|
||||
|
||||
34
src/app/templates/page-shell/page-shell.component.ts
Normal file
34
src/app/templates/page-shell/page-shell.component.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { PageLayoutComponent } from '../page-layout/page-layout.component';
|
||||
import { HeadingComponent } from '../../atoms/heading/heading.component';
|
||||
import { LinkComponent } from '../../atoms/link/link.component';
|
||||
|
||||
/** Template: standard page body inside the chrome — optional back-link, a
|
||||
heading, optional intro, and projected content. Every content page plugs
|
||||
into this, so the heading/back-link markup lives in one place. */
|
||||
@Component({
|
||||
selector: 'app-page-shell',
|
||||
imports: [PageLayoutComponent, HeadingComponent, LinkComponent],
|
||||
styles: [':host{display:block}'],
|
||||
template: `
|
||||
<app-page-layout>
|
||||
<div [style.max-width]="width() === 'narrow' ? '32rem' : null">
|
||||
@if (backLink()) {
|
||||
<p><app-link [to]="backLink()!">← {{ backLabel() }}</app-link></p>
|
||||
}
|
||||
<app-heading [level]="1">{{ heading() }}</app-heading>
|
||||
@if (intro()) {
|
||||
<p class="rhc-paragraph" style="margin-bottom:1.5rem">{{ intro() }}</p>
|
||||
}
|
||||
<ng-content />
|
||||
</div>
|
||||
</app-page-layout>
|
||||
`,
|
||||
})
|
||||
export class PageShellComponent {
|
||||
heading = input.required<string>();
|
||||
intro = input<string>();
|
||||
backLink = input<string>();
|
||||
backLabel = input('Terug naar overzicht');
|
||||
width = input<'default' | 'narrow'>('default');
|
||||
}
|
||||
34
src/app/templates/page-shell/page-shell.stories.ts
Normal file
34
src/app/templates/page-shell/page-shell.stories.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { applicationConfig, moduleMetadata } from '@storybook/angular';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { PageShellComponent } from './page-shell.component';
|
||||
import { ButtonComponent } from '../../atoms/button/button.component';
|
||||
|
||||
const meta: Meta<PageShellComponent> = {
|
||||
title: 'Templates/PageShell',
|
||||
component: PageShellComponent,
|
||||
decorators: [
|
||||
applicationConfig({ providers: [provideRouter([])] }),
|
||||
moduleMetadata({ imports: [ButtonComponent] }),
|
||||
],
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<app-page-shell [heading]="heading" [intro]="intro" [backLink]="backLink" [width]="width">
|
||||
<p class="rhc-paragraph">Pagina-inhoud wordt hier geprojecteerd.</p>
|
||||
<app-button variant="primary">Een actie</app-button>
|
||||
</app-page-shell>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<PageShellComponent>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: { heading: 'Mijn BIG-registratie', intro: 'Overzicht van uw registratie.' },
|
||||
};
|
||||
export const WithBackLink: Story = {
|
||||
args: { heading: 'Mijn gegevens', backLink: '/dashboard' },
|
||||
};
|
||||
export const Narrow: Story = {
|
||||
args: { heading: 'Inloggen', width: 'narrow', intro: 'Log in op uw omgeving.' },
|
||||
};
|
||||
Reference in New Issue
Block a user