From f1f4f982a65c119421540728f3f8135e5704b1e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 14:53:28 +0200 Subject: [PATCH] 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 + 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 --- README.md | 161 +- angular.json | 3 +- documentation.json | 1448 +++++++++++++++-- src/app/app.config.ts | 5 +- src/app/atoms/skeleton/skeleton.component.ts | 33 + src/app/atoms/skeleton/skeleton.stories.ts | 13 + src/app/atoms/spinner/spinner.component.ts | 28 + src/app/atoms/spinner/spinner.stories.ts | 12 + src/app/core/registration.service.ts | 21 +- src/app/core/scenario.interceptor.ts | 29 + src/app/core/scenario.ts | 9 + src/app/molecules/async/async.component.ts | 99 ++ src/app/molecules/async/async.stories.ts | 43 + .../login-form/login-form.component.ts | 8 +- .../site-footer/site-footer.component.ts | 5 +- .../site-header/site-header.component.ts | 7 +- src/app/pages/dashboard/dashboard.page.ts | 49 +- .../herregistratie/herregistratie.page.ts | 15 +- src/app/pages/login/login.page.ts | 9 +- .../registration-detail.page.ts | 33 +- .../page-layout/page-layout.component.ts | 15 +- .../page-shell/page-shell.component.ts | 34 + .../page-shell/page-shell.stories.ts | 34 + 23 files changed, 1888 insertions(+), 225 deletions(-) create mode 100644 src/app/atoms/skeleton/skeleton.component.ts create mode 100644 src/app/atoms/skeleton/skeleton.stories.ts create mode 100644 src/app/atoms/spinner/spinner.component.ts create mode 100644 src/app/atoms/spinner/spinner.stories.ts create mode 100644 src/app/core/scenario.interceptor.ts create mode 100644 src/app/core/scenario.ts create mode 100644 src/app/molecules/async/async.component.ts create mode 100644 src/app/molecules/async/async.stories.ts create mode 100644 src/app/templates/page-shell/page-shell.component.ts create mode 100644 src/app/templates/page-shell/page-shell.stories.ts diff --git a/README.md b/README.md index c9be484..9a1a189 100644 --- a/README.md +++ b/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 `` (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 `…`. + +**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 ``. 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 **``** 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 + + + + + +``` + +- **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 ``, 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. diff --git a/angular.json b/angular.json index cf5188b..a74f761 100644 --- a/angular.json +++ b/angular.json @@ -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": { diff --git a/documentation.json b/documentation.json index 67a48f6..e63166c 100644 --- a/documentation.json +++ b/documentation.json @@ -135,51 +135,36 @@ "injectables": [ { "name": "RegistrationService", - "id": "injectable-RegistrationService-5839a5301ce57f9632e3d5d7716d2c847c4d606b9d9fe4c66c60accb5b5f8558c192b30eacda70cdce4fe1519baab9db11cdcc2d05f9fef144c727798da8d53a", + "id": "injectable-RegistrationService-03230b1d6b08769d82b6b7d378749dd92ae4e2487883f5e23a741e40a04d5d54b22e38f1301b13de7a23c8d0e57015e3f77dfb741fb51883d9c30d7bdabd0291", "file": "src/app/core/registration.service.ts", - "properties": [ - { - "name": "http", - "defaultValue": "inject(HttpClient)", - "deprecated": false, - "deprecationMessage": "", - "type": "unknown", - "indexKey": "", - "optional": false, - "description": "", - "line": 8, - "modifierKind": [ - 123 - ] - } - ], + "properties": [], "methods": [ { - "name": "getAantekeningen", + "name": "aantekeningenResource", "args": [], "optional": false, - "returnType": "Observable", + "returnType": "any", "typeParameters": [], - "line": 14, + "line": 17, "deprecated": false, "deprecationMessage": "" }, { - "name": "getRegistration", + "name": "registrationResource", "args": [], "optional": false, - "returnType": "Observable", + "returnType": "any", "typeParameters": [], - "line": 10, + "line": 13, "deprecated": false, "deprecationMessage": "" } ], "deprecated": false, "deprecationMessage": "", - "description": "", - "rawdescription": "\n", - "sourceCode": "import { Injectable, inject } from '@angular/core';\nimport { HttpClient } from '@angular/common/http';\nimport { Observable } from 'rxjs';\nimport { Registration, Aantekening } from './models';\n\n@Injectable({ providedIn: 'root' })\nexport class RegistrationService {\n private http = inject(HttpClient);\n\n getRegistration(): Observable {\n return this.http.get('mock/registration.json');\n }\n\n getAantekeningen(): Observable {\n return this.http.get('mock/notes.json');\n }\n}\n", + "description": "

Exposes signal-based resources (Angular's httpResource). Each returns a\nResource with status()/value()/error()/reload() — consumed by ,\nwhich turns those signals into the loading/empty/error/loaded UI.\nCall these from a component field initializer (injection context required).

\n", + "rawdescription": "\n\nExposes signal-based resources (Angular's httpResource). Each returns a\nResource with status()/value()/error()/reload() — consumed by ,\nwhich turns those signals into the loading/empty/error/loaded UI.\nCall these from a component field initializer (injection context required).\n", + "sourceCode": "import { Injectable } from '@angular/core';\nimport { httpResource } from '@angular/common/http';\nimport { Registration, Aantekening } from './models';\n\n/**\n * Exposes signal-based resources (Angular's httpResource). Each returns a\n * Resource with status()/value()/error()/reload() — consumed by ,\n * which turns those signals into the loading/empty/error/loaded UI.\n * Call these from a component field initializer (injection context required).\n */\n@Injectable({ providedIn: 'root' })\nexport class RegistrationService {\n registrationResource() {\n return httpResource(() => 'mock/registration.json');\n }\n\n aantekeningenResource() {\n return httpResource(() => 'mock/notes.json', { defaultValue: [] });\n }\n}\n", "extends": [], "type": "injectable" } @@ -187,7 +172,272 @@ "guards": [], "interceptors": [], "classes": [], - "directives": [], + "directives": [ + { + "name": "AsyncEmptyDirective", + "id": "directive-AsyncEmptyDirective-2f8f54349a396c3f59c90536339a2de5b28ea23c435f301c585563e8dcd7e5652d6b6dee37cd8065a1f9573e0fbbc44511e14a02310dba1fd9d0a8354991e759", + "file": "src/app/molecules/async/async.component.ts", + "type": "directive", + "description": "", + "rawdescription": "\n", + "sourceCode": "import { Component, Directive, TemplateRef, computed, contentChild, input } from '@angular/core';\nimport { NgTemplateOutlet } from '@angular/common';\nimport type { Resource } from '@angular/core';\nimport { SpinnerComponent } from '../../atoms/spinner/spinner.component';\nimport { AlertComponent } from '../../atoms/alert/alert.component';\nimport { ButtonComponent } from '../../atoms/button/button.component';\n\n/* Slot markers. Put on children of . */\n@Directive({ selector: '[appAsyncLoaded]' })\nexport class AsyncLoadedDirective {\n constructor(public tpl: TemplateRef<{ $implicit: unknown }>) {}\n}\n@Directive({ selector: '[appAsyncLoading]' })\nexport class AsyncLoadingDirective {\n constructor(public tpl: TemplateRef) {}\n}\n@Directive({ selector: '[appAsyncEmpty]' })\nexport class AsyncEmptyDirective {\n constructor(public tpl: TemplateRef) {}\n}\n@Directive({ selector: '[appAsyncError]' })\nexport class AsyncErrorDirective {\n constructor(public tpl: TemplateRef<{ $implicit: Error | undefined; retry: () => void }>) {}\n}\n\ntype State = 'loading' | 'error' | 'empty' | 'loaded';\n\n/**\n * Renders exactly ONE of loading / empty / error / loaded for a signal-based\n * resource (e.g. httpResource). The states are mutually exclusive by\n * construction, so the UI can never show two at once (\"impossible states\").\n * Unprovided slots fall back to sensible defaults.\n */\n@Component({\n selector: 'app-async',\n imports: [NgTemplateOutlet, SpinnerComponent, AlertComponent, ButtonComponent],\n template: `\n @switch (state()) {\n @case ('loading') {\n @if (loadingTpl()) { }\n @else { }\n }\n @case ('error') {\n @if (errorTpl()) {\n \n } @else {\n Er ging iets mis bij het laden van de gegevens.\n
\n Opnieuw proberen\n
\n }\n }\n @case ('empty') {\n @if (emptyTpl()) { }\n @else {

Geen gegevens gevonden.

}\n }\n @case ('loaded') {\n \n }\n }\n `,\n})\nexport class AsyncComponent {\n resource = input.required>();\n isEmpty = input<(v: T) => boolean>(() => false);\n\n loadedTpl = contentChild.required(AsyncLoadedDirective);\n loadingTpl = contentChild(AsyncLoadingDirective);\n emptyTpl = contentChild(AsyncEmptyDirective);\n errorTpl = contentChild(AsyncErrorDirective);\n\n protected value = computed(() => {\n const r = this.resource();\n return r.hasValue() ? r.value() : undefined;\n });\n\n protected state = computed(() => {\n const r = this.resource();\n if (r.status() === 'error') return 'error';\n if (r.status() === 'loading') return 'loading';\n if (r.hasValue()) return this.isEmpty()(r.value()) ? 'empty' : 'loaded';\n return 'loading';\n });\n\n retry = () => {\n const r = this.resource();\n if ('reload' in r && typeof (r as { reload?: unknown }).reload === 'function') {\n (r as { reload: () => void }).reload();\n }\n };\n}\n\n/** Convenience: import this array to get the wrapper + all slot directives. */\nexport const ASYNC = [\n AsyncComponent, AsyncLoadedDirective, AsyncLoadingDirective,\n AsyncEmptyDirective, AsyncErrorDirective,\n] as const;\n", + "selector": "[appAsyncEmpty]", + "providers": [], + "hostDirectives": [], + "standalone": false, + "inputsClass": [], + "outputsClass": [], + "deprecated": false, + "deprecationMessage": "", + "hostBindings": [], + "hostListeners": [], + "propertiesClass": [ + { + "name": "tpl", + "deprecated": false, + "deprecationMessage": "", + "type": "TemplateRef", + "indexKey": "", + "optional": false, + "description": "", + "line": 19, + "modifierKind": [ + 125 + ] + } + ], + "methodsClass": [], + "extends": [], + "constructorObj": { + "name": "constructor", + "description": "", + "deprecated": false, + "deprecationMessage": "", + "args": [ + { + "name": "tpl", + "type": "TemplateRef", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "" + } + ], + "line": 18, + "jsdoctags": [ + { + "name": "tpl", + "type": "TemplateRef", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + } + }, + { + "name": "AsyncErrorDirective", + "id": "directive-AsyncErrorDirective-2f8f54349a396c3f59c90536339a2de5b28ea23c435f301c585563e8dcd7e5652d6b6dee37cd8065a1f9573e0fbbc44511e14a02310dba1fd9d0a8354991e759", + "file": "src/app/molecules/async/async.component.ts", + "type": "directive", + "description": "", + "rawdescription": "\n", + "sourceCode": "import { Component, Directive, TemplateRef, computed, contentChild, input } from '@angular/core';\nimport { NgTemplateOutlet } from '@angular/common';\nimport type { Resource } from '@angular/core';\nimport { SpinnerComponent } from '../../atoms/spinner/spinner.component';\nimport { AlertComponent } from '../../atoms/alert/alert.component';\nimport { ButtonComponent } from '../../atoms/button/button.component';\n\n/* Slot markers. Put on children of . */\n@Directive({ selector: '[appAsyncLoaded]' })\nexport class AsyncLoadedDirective {\n constructor(public tpl: TemplateRef<{ $implicit: unknown }>) {}\n}\n@Directive({ selector: '[appAsyncLoading]' })\nexport class AsyncLoadingDirective {\n constructor(public tpl: TemplateRef) {}\n}\n@Directive({ selector: '[appAsyncEmpty]' })\nexport class AsyncEmptyDirective {\n constructor(public tpl: TemplateRef) {}\n}\n@Directive({ selector: '[appAsyncError]' })\nexport class AsyncErrorDirective {\n constructor(public tpl: TemplateRef<{ $implicit: Error | undefined; retry: () => void }>) {}\n}\n\ntype State = 'loading' | 'error' | 'empty' | 'loaded';\n\n/**\n * Renders exactly ONE of loading / empty / error / loaded for a signal-based\n * resource (e.g. httpResource). The states are mutually exclusive by\n * construction, so the UI can never show two at once (\"impossible states\").\n * Unprovided slots fall back to sensible defaults.\n */\n@Component({\n selector: 'app-async',\n imports: [NgTemplateOutlet, SpinnerComponent, AlertComponent, ButtonComponent],\n template: `\n @switch (state()) {\n @case ('loading') {\n @if (loadingTpl()) { }\n @else { }\n }\n @case ('error') {\n @if (errorTpl()) {\n \n } @else {\n Er ging iets mis bij het laden van de gegevens.\n
\n Opnieuw proberen\n
\n }\n }\n @case ('empty') {\n @if (emptyTpl()) { }\n @else {

Geen gegevens gevonden.

}\n }\n @case ('loaded') {\n \n }\n }\n `,\n})\nexport class AsyncComponent {\n resource = input.required>();\n isEmpty = input<(v: T) => boolean>(() => false);\n\n loadedTpl = contentChild.required(AsyncLoadedDirective);\n loadingTpl = contentChild(AsyncLoadingDirective);\n emptyTpl = contentChild(AsyncEmptyDirective);\n errorTpl = contentChild(AsyncErrorDirective);\n\n protected value = computed(() => {\n const r = this.resource();\n return r.hasValue() ? r.value() : undefined;\n });\n\n protected state = computed(() => {\n const r = this.resource();\n if (r.status() === 'error') return 'error';\n if (r.status() === 'loading') return 'loading';\n if (r.hasValue()) return this.isEmpty()(r.value()) ? 'empty' : 'loaded';\n return 'loading';\n });\n\n retry = () => {\n const r = this.resource();\n if ('reload' in r && typeof (r as { reload?: unknown }).reload === 'function') {\n (r as { reload: () => void }).reload();\n }\n };\n}\n\n/** Convenience: import this array to get the wrapper + all slot directives. */\nexport const ASYNC = [\n AsyncComponent, AsyncLoadedDirective, AsyncLoadingDirective,\n AsyncEmptyDirective, AsyncErrorDirective,\n] as const;\n", + "selector": "[appAsyncError]", + "providers": [], + "hostDirectives": [], + "standalone": false, + "inputsClass": [], + "outputsClass": [], + "deprecated": false, + "deprecationMessage": "", + "hostBindings": [], + "hostListeners": [], + "propertiesClass": [ + { + "name": "tpl", + "deprecated": false, + "deprecationMessage": "", + "type": "TemplateRef", + "indexKey": "", + "optional": false, + "description": "", + "line": 23, + "modifierKind": [ + 125 + ] + } + ], + "methodsClass": [], + "extends": [], + "constructorObj": { + "name": "constructor", + "description": "", + "deprecated": false, + "deprecationMessage": "", + "args": [ + { + "name": "tpl", + "type": "TemplateRef", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "" + } + ], + "line": 22, + "jsdoctags": [ + { + "name": "tpl", + "type": "TemplateRef", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + } + }, + { + "name": "AsyncLoadedDirective", + "id": "directive-AsyncLoadedDirective-2f8f54349a396c3f59c90536339a2de5b28ea23c435f301c585563e8dcd7e5652d6b6dee37cd8065a1f9573e0fbbc44511e14a02310dba1fd9d0a8354991e759", + "file": "src/app/molecules/async/async.component.ts", + "type": "directive", + "description": "", + "rawdescription": "\n", + "sourceCode": "import { Component, Directive, TemplateRef, computed, contentChild, input } from '@angular/core';\nimport { NgTemplateOutlet } from '@angular/common';\nimport type { Resource } from '@angular/core';\nimport { SpinnerComponent } from '../../atoms/spinner/spinner.component';\nimport { AlertComponent } from '../../atoms/alert/alert.component';\nimport { ButtonComponent } from '../../atoms/button/button.component';\n\n/* Slot markers. Put on children of . */\n@Directive({ selector: '[appAsyncLoaded]' })\nexport class AsyncLoadedDirective {\n constructor(public tpl: TemplateRef<{ $implicit: unknown }>) {}\n}\n@Directive({ selector: '[appAsyncLoading]' })\nexport class AsyncLoadingDirective {\n constructor(public tpl: TemplateRef) {}\n}\n@Directive({ selector: '[appAsyncEmpty]' })\nexport class AsyncEmptyDirective {\n constructor(public tpl: TemplateRef) {}\n}\n@Directive({ selector: '[appAsyncError]' })\nexport class AsyncErrorDirective {\n constructor(public tpl: TemplateRef<{ $implicit: Error | undefined; retry: () => void }>) {}\n}\n\ntype State = 'loading' | 'error' | 'empty' | 'loaded';\n\n/**\n * Renders exactly ONE of loading / empty / error / loaded for a signal-based\n * resource (e.g. httpResource). The states are mutually exclusive by\n * construction, so the UI can never show two at once (\"impossible states\").\n * Unprovided slots fall back to sensible defaults.\n */\n@Component({\n selector: 'app-async',\n imports: [NgTemplateOutlet, SpinnerComponent, AlertComponent, ButtonComponent],\n template: `\n @switch (state()) {\n @case ('loading') {\n @if (loadingTpl()) { }\n @else { }\n }\n @case ('error') {\n @if (errorTpl()) {\n \n } @else {\n Er ging iets mis bij het laden van de gegevens.\n
\n Opnieuw proberen\n
\n }\n }\n @case ('empty') {\n @if (emptyTpl()) { }\n @else {

Geen gegevens gevonden.

}\n }\n @case ('loaded') {\n \n }\n }\n `,\n})\nexport class AsyncComponent {\n resource = input.required>();\n isEmpty = input<(v: T) => boolean>(() => false);\n\n loadedTpl = contentChild.required(AsyncLoadedDirective);\n loadingTpl = contentChild(AsyncLoadingDirective);\n emptyTpl = contentChild(AsyncEmptyDirective);\n errorTpl = contentChild(AsyncErrorDirective);\n\n protected value = computed(() => {\n const r = this.resource();\n return r.hasValue() ? r.value() : undefined;\n });\n\n protected state = computed(() => {\n const r = this.resource();\n if (r.status() === 'error') return 'error';\n if (r.status() === 'loading') return 'loading';\n if (r.hasValue()) return this.isEmpty()(r.value()) ? 'empty' : 'loaded';\n return 'loading';\n });\n\n retry = () => {\n const r = this.resource();\n if ('reload' in r && typeof (r as { reload?: unknown }).reload === 'function') {\n (r as { reload: () => void }).reload();\n }\n };\n}\n\n/** Convenience: import this array to get the wrapper + all slot directives. */\nexport const ASYNC = [\n AsyncComponent, AsyncLoadedDirective, AsyncLoadingDirective,\n AsyncEmptyDirective, AsyncErrorDirective,\n] as const;\n", + "selector": "[appAsyncLoaded]", + "providers": [], + "hostDirectives": [], + "standalone": false, + "inputsClass": [], + "outputsClass": [], + "deprecated": false, + "deprecationMessage": "", + "hostBindings": [], + "hostListeners": [], + "propertiesClass": [ + { + "name": "tpl", + "deprecated": false, + "deprecationMessage": "", + "type": "TemplateRef", + "indexKey": "", + "optional": false, + "description": "", + "line": 11, + "modifierKind": [ + 125 + ] + } + ], + "methodsClass": [], + "extends": [], + "constructorObj": { + "name": "constructor", + "description": "", + "deprecated": false, + "deprecationMessage": "", + "args": [ + { + "name": "tpl", + "type": "TemplateRef", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "" + } + ], + "line": 10, + "jsdoctags": [ + { + "name": "tpl", + "type": "TemplateRef", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + } + }, + { + "name": "AsyncLoadingDirective", + "id": "directive-AsyncLoadingDirective-2f8f54349a396c3f59c90536339a2de5b28ea23c435f301c585563e8dcd7e5652d6b6dee37cd8065a1f9573e0fbbc44511e14a02310dba1fd9d0a8354991e759", + "file": "src/app/molecules/async/async.component.ts", + "type": "directive", + "description": "", + "rawdescription": "\n", + "sourceCode": "import { Component, Directive, TemplateRef, computed, contentChild, input } from '@angular/core';\nimport { NgTemplateOutlet } from '@angular/common';\nimport type { Resource } from '@angular/core';\nimport { SpinnerComponent } from '../../atoms/spinner/spinner.component';\nimport { AlertComponent } from '../../atoms/alert/alert.component';\nimport { ButtonComponent } from '../../atoms/button/button.component';\n\n/* Slot markers. Put on children of . */\n@Directive({ selector: '[appAsyncLoaded]' })\nexport class AsyncLoadedDirective {\n constructor(public tpl: TemplateRef<{ $implicit: unknown }>) {}\n}\n@Directive({ selector: '[appAsyncLoading]' })\nexport class AsyncLoadingDirective {\n constructor(public tpl: TemplateRef) {}\n}\n@Directive({ selector: '[appAsyncEmpty]' })\nexport class AsyncEmptyDirective {\n constructor(public tpl: TemplateRef) {}\n}\n@Directive({ selector: '[appAsyncError]' })\nexport class AsyncErrorDirective {\n constructor(public tpl: TemplateRef<{ $implicit: Error | undefined; retry: () => void }>) {}\n}\n\ntype State = 'loading' | 'error' | 'empty' | 'loaded';\n\n/**\n * Renders exactly ONE of loading / empty / error / loaded for a signal-based\n * resource (e.g. httpResource). The states are mutually exclusive by\n * construction, so the UI can never show two at once (\"impossible states\").\n * Unprovided slots fall back to sensible defaults.\n */\n@Component({\n selector: 'app-async',\n imports: [NgTemplateOutlet, SpinnerComponent, AlertComponent, ButtonComponent],\n template: `\n @switch (state()) {\n @case ('loading') {\n @if (loadingTpl()) { }\n @else { }\n }\n @case ('error') {\n @if (errorTpl()) {\n \n } @else {\n Er ging iets mis bij het laden van de gegevens.\n
\n Opnieuw proberen\n
\n }\n }\n @case ('empty') {\n @if (emptyTpl()) { }\n @else {

Geen gegevens gevonden.

}\n }\n @case ('loaded') {\n \n }\n }\n `,\n})\nexport class AsyncComponent {\n resource = input.required>();\n isEmpty = input<(v: T) => boolean>(() => false);\n\n loadedTpl = contentChild.required(AsyncLoadedDirective);\n loadingTpl = contentChild(AsyncLoadingDirective);\n emptyTpl = contentChild(AsyncEmptyDirective);\n errorTpl = contentChild(AsyncErrorDirective);\n\n protected value = computed(() => {\n const r = this.resource();\n return r.hasValue() ? r.value() : undefined;\n });\n\n protected state = computed(() => {\n const r = this.resource();\n if (r.status() === 'error') return 'error';\n if (r.status() === 'loading') return 'loading';\n if (r.hasValue()) return this.isEmpty()(r.value()) ? 'empty' : 'loaded';\n return 'loading';\n });\n\n retry = () => {\n const r = this.resource();\n if ('reload' in r && typeof (r as { reload?: unknown }).reload === 'function') {\n (r as { reload: () => void }).reload();\n }\n };\n}\n\n/** Convenience: import this array to get the wrapper + all slot directives. */\nexport const ASYNC = [\n AsyncComponent, AsyncLoadedDirective, AsyncLoadingDirective,\n AsyncEmptyDirective, AsyncErrorDirective,\n] as const;\n", + "selector": "[appAsyncLoading]", + "providers": [], + "hostDirectives": [], + "standalone": false, + "inputsClass": [], + "outputsClass": [], + "deprecated": false, + "deprecationMessage": "", + "hostBindings": [], + "hostListeners": [], + "propertiesClass": [ + { + "name": "tpl", + "deprecated": false, + "deprecationMessage": "", + "type": "TemplateRef", + "indexKey": "", + "optional": false, + "description": "", + "line": 15, + "modifierKind": [ + 125 + ] + } + ], + "methodsClass": [], + "extends": [], + "constructorObj": { + "name": "constructor", + "description": "", + "deprecated": false, + "deprecationMessage": "", + "args": [ + { + "name": "tpl", + "type": "TemplateRef", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "" + } + ], + "line": 14, + "jsdoctags": [ + { + "name": "tpl", + "type": "TemplateRef", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + } + } + ], "components": [ { "name": "AlertComponent", @@ -276,6 +526,165 @@ "stylesData": "", "extends": [] }, + { + "name": "AsyncComponent", + "id": "component-AsyncComponent-2f8f54349a396c3f59c90536339a2de5b28ea23c435f301c585563e8dcd7e5652d6b6dee37cd8065a1f9573e0fbbc44511e14a02310dba1fd9d0a8354991e759", + "file": "src/app/molecules/async/async.component.ts", + "encapsulation": [], + "entryComponents": [], + "inputs": [], + "outputs": [], + "providers": [], + "selector": "app-async", + "styleUrls": [], + "styles": [], + "template": "@switch (state()) {\n @case ('loading') {\n @if (loadingTpl()) { }\n @else { }\n }\n @case ('error') {\n @if (errorTpl()) {\n \n } @else {\n Er ging iets mis bij het laden van de gegevens.\n
\n Opnieuw proberen\n
\n }\n }\n @case ('empty') {\n @if (emptyTpl()) { }\n @else {

Geen gegevens gevonden.

}\n }\n @case ('loaded') {\n \n }\n}\n", + "templateUrl": [], + "viewProviders": [], + "hostDirectives": [], + "inputsClass": [ + { + "name": "isEmpty", + "defaultValue": "() => false", + "deprecated": false, + "deprecationMessage": "", + "type": "(v: T) => boolean", + "indexKey": "", + "optional": false, + "description": "", + "line": 67, + "required": false + }, + { + "name": "resource", + "deprecated": false, + "deprecationMessage": "", + "type": "Resource", + "indexKey": "", + "optional": false, + "description": "", + "line": 66, + "required": true + } + ], + "outputsClass": [], + "propertiesClass": [ + { + "name": "emptyTpl", + "defaultValue": "contentChild(AsyncEmptyDirective)", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 71 + }, + { + "name": "errorTpl", + "defaultValue": "contentChild(AsyncErrorDirective)", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 72 + }, + { + "name": "loadedTpl", + "defaultValue": "contentChild.required(AsyncLoadedDirective)", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 69 + }, + { + "name": "loadingTpl", + "defaultValue": "contentChild(AsyncLoadingDirective)", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 70 + }, + { + "name": "retry", + "defaultValue": "() => {...}", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 87 + }, + { + "name": "state", + "defaultValue": "computed(() => {\n const r = this.resource();\n if (r.status() === 'error') return 'error';\n if (r.status() === 'loading') return 'loading';\n if (r.hasValue()) return this.isEmpty()(r.value()) ? 'empty' : 'loaded';\n return 'loading';\n })", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 79, + "modifierKind": [ + 124 + ] + }, + { + "name": "value", + "defaultValue": "computed(() => {\n const r = this.resource();\n return r.hasValue() ? r.value() : undefined;\n })", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 74, + "modifierKind": [ + 124 + ] + } + ], + "methodsClass": [], + "deprecated": false, + "deprecationMessage": "", + "hostBindings": [], + "hostListeners": [], + "standalone": false, + "imports": [ + { + "name": "NgTemplateOutlet" + }, + { + "name": "SpinnerComponent", + "type": "component" + }, + { + "name": "AlertComponent", + "type": "component" + }, + { + "name": "ButtonComponent", + "type": "component" + } + ], + "description": "

Renders exactly ONE of loading / empty / error / loaded for a signal-based\nresource (e.g. httpResource). The states are mutually exclusive by\nconstruction, so the UI can never show two at once ("impossible states").\nUnprovided slots fall back to sensible defaults.

\n", + "rawdescription": "\n\nRenders exactly ONE of loading / empty / error / loaded for a signal-based\nresource (e.g. httpResource). The states are mutually exclusive by\nconstruction, so the UI can never show two at once (\"impossible states\").\nUnprovided slots fall back to sensible defaults.\n", + "type": "component", + "sourceCode": "import { Component, Directive, TemplateRef, computed, contentChild, input } from '@angular/core';\nimport { NgTemplateOutlet } from '@angular/common';\nimport type { Resource } from '@angular/core';\nimport { SpinnerComponent } from '../../atoms/spinner/spinner.component';\nimport { AlertComponent } from '../../atoms/alert/alert.component';\nimport { ButtonComponent } from '../../atoms/button/button.component';\n\n/* Slot markers. Put on children of . */\n@Directive({ selector: '[appAsyncLoaded]' })\nexport class AsyncLoadedDirective {\n constructor(public tpl: TemplateRef<{ $implicit: unknown }>) {}\n}\n@Directive({ selector: '[appAsyncLoading]' })\nexport class AsyncLoadingDirective {\n constructor(public tpl: TemplateRef) {}\n}\n@Directive({ selector: '[appAsyncEmpty]' })\nexport class AsyncEmptyDirective {\n constructor(public tpl: TemplateRef) {}\n}\n@Directive({ selector: '[appAsyncError]' })\nexport class AsyncErrorDirective {\n constructor(public tpl: TemplateRef<{ $implicit: Error | undefined; retry: () => void }>) {}\n}\n\ntype State = 'loading' | 'error' | 'empty' | 'loaded';\n\n/**\n * Renders exactly ONE of loading / empty / error / loaded for a signal-based\n * resource (e.g. httpResource). The states are mutually exclusive by\n * construction, so the UI can never show two at once (\"impossible states\").\n * Unprovided slots fall back to sensible defaults.\n */\n@Component({\n selector: 'app-async',\n imports: [NgTemplateOutlet, SpinnerComponent, AlertComponent, ButtonComponent],\n template: `\n @switch (state()) {\n @case ('loading') {\n @if (loadingTpl()) { }\n @else { }\n }\n @case ('error') {\n @if (errorTpl()) {\n \n } @else {\n Er ging iets mis bij het laden van de gegevens.\n
\n Opnieuw proberen\n
\n }\n }\n @case ('empty') {\n @if (emptyTpl()) { }\n @else {

Geen gegevens gevonden.

}\n }\n @case ('loaded') {\n \n }\n }\n `,\n})\nexport class AsyncComponent {\n resource = input.required>();\n isEmpty = input<(v: T) => boolean>(() => false);\n\n loadedTpl = contentChild.required(AsyncLoadedDirective);\n loadingTpl = contentChild(AsyncLoadingDirective);\n emptyTpl = contentChild(AsyncEmptyDirective);\n errorTpl = contentChild(AsyncErrorDirective);\n\n protected value = computed(() => {\n const r = this.resource();\n return r.hasValue() ? r.value() : undefined;\n });\n\n protected state = computed(() => {\n const r = this.resource();\n if (r.status() === 'error') return 'error';\n if (r.status() === 'loading') return 'loading';\n if (r.hasValue()) return this.isEmpty()(r.value()) ? 'empty' : 'loaded';\n return 'loading';\n });\n\n retry = () => {\n const r = this.resource();\n if ('reload' in r && typeof (r as { reload?: unknown }).reload === 'function') {\n (r as { reload: () => void }).reload();\n }\n };\n}\n\n/** Convenience: import this array to get the wrapper + all slot directives. */\nexport const ASYNC = [\n AsyncComponent, AsyncLoadedDirective, AsyncLoadingDirective,\n AsyncEmptyDirective, AsyncErrorDirective,\n] as const;\n", + "assetsDirs": [], + "styleUrlsData": "", + "stylesData": "", + "extends": [] + }, { "name": "ButtonComponent", "id": "component-ButtonComponent-6eb936521438d91cc3a2276d1b8e366496e907e8a01d7b84b327c3cc31fa1dfadbb9502c6646472736722cf7ced9950fcaa4d8435ddf28224b3e7c9b7bd29505", @@ -473,7 +882,7 @@ }, { "name": "DashboardPage", - "id": "component-DashboardPage-19084a8e5fb5e2539b690f3df46c4d7b9e30e6194b619a119113b3528c732259cd43608a582ad6356673a06802d7c741fcd8900d619a2641581adc22496aa404", + "id": "component-DashboardPage-36586da5e82d0e8ebdd85752f770845da4b3cc25f67a64c1fbaa2fdc227f11a3686aa7e85d20ba00f27796d75849db0f6e52fc10e1f2c59c8dd10c6c940c6c91", "file": "src/app/pages/dashboard/dashboard.page.ts", "encapsulation": [], "entryComponents": [], @@ -483,7 +892,7 @@ "selector": "app-dashboard-page", "styleUrls": [], "styles": [], - "template": "\n Mijn BIG-registratie\n\n @if (reg$ | async; as reg) {\n \n }\n\n
\n Specialismen en aantekeningen\n @if (notes$ | async; as notes) {\n \n }\n
\n\n

\n Gegevens bekijken of een wijziging doorgeven →\n

\n
\n", + "template": "\n \n \n \n \n \n \n \n \n \n\n
\n Specialismen en aantekeningen\n \n \n \n \n \n \n \n \n

U heeft nog geen specialismen of aantekeningen.

\n
\n
\n
\n\n

\n Gegevens bekijken of een wijziging doorgeven →\n

\n

\n Herregistratie aanvragen →\n

\n
\n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], @@ -491,26 +900,48 @@ "outputsClass": [], "propertiesClass": [ { - "name": "notes$", - "defaultValue": "this.svc.getAantekeningen()", + "name": "notes", + "defaultValue": "this.svc.aantekeningenResource()", "deprecated": false, "deprecationMessage": "", "type": "unknown", "indexKey": "", "optional": false, "description": "", - "line": 37 + "line": 57 }, { - "name": "reg$", - "defaultValue": "this.svc.getRegistration()", + "name": "notesEmpty", + "defaultValue": "() => {...}", "deprecated": false, "deprecationMessage": "", "type": "unknown", "indexKey": "", "optional": false, "description": "", - "line": 36 + "line": 58 + }, + { + "name": "reg", + "defaultValue": "this.svc.registrationResource()", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 56 + }, + { + "name": "regEmpty", + "defaultValue": "() => {...}", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 59 }, { "name": "svc", @@ -521,7 +952,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 35, + "line": 55, "modifierKind": [ 123 ] @@ -535,11 +966,7 @@ "standalone": false, "imports": [ { - "name": "AsyncPipe", - "type": "pipe" - }, - { - "name": "PageLayoutComponent", + "name": "PageShellComponent", "type": "component" }, { @@ -550,6 +977,13 @@ "name": "LinkComponent", "type": "component" }, + { + "name": "SkeletonComponent", + "type": "component" + }, + { + "name": "ASYNC" + }, { "name": "RegistrationSummaryComponent", "type": "component" @@ -562,7 +996,7 @@ "description": "", "rawdescription": "\n", "type": "component", - "sourceCode": "import { Component, inject } from '@angular/core';\nimport { AsyncPipe } from '@angular/common';\nimport { PageLayoutComponent } from '../../templates/page-layout/page-layout.component';\nimport { HeadingComponent } from '../../atoms/heading/heading.component';\nimport { LinkComponent } from '../../atoms/link/link.component';\nimport { RegistrationSummaryComponent } from '../../organisms/registration-summary/registration-summary.component';\nimport { RegistrationTableComponent } from '../../organisms/registration-table/registration-table.component';\nimport { RegistrationService } from '../../core/registration.service';\n\n@Component({\n selector: 'app-dashboard-page',\n imports: [AsyncPipe, PageLayoutComponent, HeadingComponent, LinkComponent, RegistrationSummaryComponent, RegistrationTableComponent],\n template: `\n \n Mijn BIG-registratie\n\n @if (reg$ | async; as reg) {\n \n }\n\n
\n Specialismen en aantekeningen\n @if (notes$ | async; as notes) {\n \n }\n
\n\n

\n Gegevens bekijken of een wijziging doorgeven →\n

\n
\n `,\n})\nexport class DashboardPage {\n private svc = inject(RegistrationService);\n reg$ = this.svc.getRegistration();\n notes$ = this.svc.getAantekeningen();\n}\n", + "sourceCode": "import { Component, inject } from '@angular/core';\nimport { PageShellComponent } from '../../templates/page-shell/page-shell.component';\nimport { HeadingComponent } from '../../atoms/heading/heading.component';\nimport { LinkComponent } from '../../atoms/link/link.component';\nimport { SkeletonComponent } from '../../atoms/skeleton/skeleton.component';\nimport { ASYNC } from '../../molecules/async/async.component';\nimport { RegistrationSummaryComponent } from '../../organisms/registration-summary/registration-summary.component';\nimport { RegistrationTableComponent } from '../../organisms/registration-table/registration-table.component';\nimport { RegistrationService } from '../../core/registration.service';\nimport { Aantekening } from '../../core/models';\n\n@Component({\n selector: 'app-dashboard-page',\n imports: [\n PageShellComponent, HeadingComponent, LinkComponent, SkeletonComponent, ...ASYNC,\n RegistrationSummaryComponent, RegistrationTableComponent,\n ],\n template: `\n \n \n \n \n \n \n \n \n \n \n\n
\n Specialismen en aantekeningen\n \n \n \n \n \n \n \n \n

U heeft nog geen specialismen of aantekeningen.

\n
\n
\n
\n\n

\n Gegevens bekijken of een wijziging doorgeven →\n

\n

\n Herregistratie aanvragen →\n

\n
\n `,\n})\nexport class DashboardPage {\n private svc = inject(RegistrationService);\n reg = this.svc.registrationResource();\n notes = this.svc.aantekeningenResource();\n notesEmpty = (v: Aantekening[]) => v.length === 0;\n regEmpty = (v: unknown) => !v || Object.keys(v).length === 0;\n}\n", "assetsDirs": [], "styleUrlsData": "", "stylesData": "", @@ -709,7 +1143,7 @@ }, { "name": "HeadingComponent", - "id": "component-HeadingComponent-dd6481ed78575bd2b94402bca9393970625d00c82519874b750b121317b776eeffba477c24eff95dc07a1fb77990ad12eee11c678dfe83611f6d658a01b66a10", + "id": "component-HeadingComponent-0f0b442fc9ccbfde8481137b728000bb093868ddc931f507bbd4a86ee528820cf4c6dab36a9a82b4a940bb96c2355f42bc34a94700acc7df3e7dfeea627be67c", "file": "src/app/atoms/heading/heading.component.ts", "encapsulation": [], "entryComponents": [], @@ -719,7 +1153,7 @@ "selector": "app-heading", "styleUrls": [], "styles": [], - "template": "@switch (level()) {\n @case (1) {

}\n @case (2) {

}\n @case (3) {

}\n @case (4) {

}\n @default {
}\n}\n", + "template": "\n@switch (level()) {\n @case (1) {

}\n @case (2) {

}\n @case (3) {

}\n @case (4) {

}\n @default {
}\n}\n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], @@ -733,7 +1167,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 17, + "line": 22, "required": false } ], @@ -745,11 +1179,131 @@ "hostBindings": [], "hostListeners": [], "standalone": false, - "imports": [], - "description": "

Atom: heading. Renders the right h1..h5 with RHC heading styling.

\n", - "rawdescription": "\nAtom: heading. Renders the right h1..h5 with RHC heading styling.", + "imports": [ + { + "name": "NgTemplateOutlet" + } + ], + "description": "

Atom: heading. Renders the right h1..h5 with RHC heading styling.\nSingle captured in a template — multiple ng-content across

\n", + "rawdescription": "\nAtom: heading. Renders the right h1..h5 with RHC heading styling.\nSingle captured in a template — multiple ng-content across", "type": "component", - "sourceCode": "import { Component, input } from '@angular/core';\n\n/** Atom: heading. Renders the right h1..h5 with RHC heading styling. */\n@Component({\n selector: 'app-heading',\n template: `\n @switch (level()) {\n @case (1) {

}\n @case (2) {

}\n @case (3) {

}\n @case (4) {

}\n @default {
}\n }\n `,\n})\nexport class HeadingComponent {\n level = input<1 | 2 | 3 | 4 | 5>(2);\n}\n", + "sourceCode": "import { Component, input } from '@angular/core';\nimport { NgTemplateOutlet } from '@angular/common';\n\n/** Atom: heading. Renders the right h1..h5 with RHC heading styling.\n Single captured in a template — multiple ng-content across\n @switch branches silently drops the projected content. */\n@Component({\n selector: 'app-heading',\n imports: [NgTemplateOutlet],\n template: `\n \n @switch (level()) {\n @case (1) {

}\n @case (2) {

}\n @case (3) {

}\n @case (4) {

}\n @default {
}\n }\n `,\n})\nexport class HeadingComponent {\n level = input<1 | 2 | 3 | 4 | 5>(2);\n}\n", + "assetsDirs": [], + "styleUrlsData": "", + "stylesData": "", + "extends": [] + }, + { + "name": "HerregistratiePage", + "id": "component-HerregistratiePage-ab0497d7957e16f74b4c042109d559a772b5fd5c364d0d3c721504a6467354672c7afef26ddf84aa3d7a9e64ec48213993f5550e57ca65a536d10403b61ec754", + "file": "src/app/pages/herregistratie/herregistratie.page.ts", + "encapsulation": [], + "entryComponents": [], + "inputs": [], + "outputs": [], + "providers": [], + "selector": "app-herregistratie-page", + "styleUrls": [], + "styles": [], + "template": "\n \n Uw huidige registratie verloopt op 1 september 2027. Vraag tijdig herregistratie aan.\n \n\n @if (submitted()) {\n
\n Uw aanvraag tot herregistratie is ontvangen.\n
\n } @else {\n
\n \n \n \n \n \n \n
\n Herregistratie aanvragen\n
\n
\n }\n
\n", + "templateUrl": [], + "viewProviders": [], + "hostDirectives": [], + "inputsClass": [], + "outputsClass": [], + "propertiesClass": [ + { + "name": "punten", + "defaultValue": "''", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 45 + }, + { + "name": "submitted", + "defaultValue": "signal(false)", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 47 + }, + { + "name": "uren", + "defaultValue": "''", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 44 + }, + { + "name": "urenError", + "defaultValue": "signal('')", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 46 + } + ], + "methodsClass": [ + { + "name": "onSubmit", + "args": [], + "optional": false, + "returnType": "void", + "typeParameters": [], + "line": 49, + "deprecated": false, + "deprecationMessage": "" + } + ], + "deprecated": false, + "deprecationMessage": "", + "hostBindings": [], + "hostListeners": [], + "standalone": false, + "imports": [ + { + "name": "FormsModule", + "type": "module" + }, + { + "name": "PageShellComponent", + "type": "component" + }, + { + "name": "AlertComponent", + "type": "component" + }, + { + "name": "ButtonComponent", + "type": "component" + }, + { + "name": "FormFieldComponent", + "type": "component" + }, + { + "name": "TextInputComponent", + "type": "component" + } + ], + "description": "

A whole new page built from existing building blocks — no new components.\nThis is the atomic-design payoff: a new flow is just composition.

\n", + "rawdescription": "\nA whole new page built from existing building blocks — no new components.\nThis is the atomic-design payoff: a new flow is just composition.", + "type": "component", + "sourceCode": "import { Component, signal } from '@angular/core';\nimport { FormsModule } from '@angular/forms';\nimport { PageShellComponent } from '../../templates/page-shell/page-shell.component';\nimport { AlertComponent } from '../../atoms/alert/alert.component';\nimport { ButtonComponent } from '../../atoms/button/button.component';\nimport { FormFieldComponent } from '../../molecules/form-field/form-field.component';\nimport { TextInputComponent } from '../../atoms/text-input/text-input.component';\n\n/** A whole new page built from existing building blocks — no new components.\n This is the atomic-design payoff: a new flow is just composition. */\n@Component({\n selector: 'app-herregistratie-page',\n imports: [\n FormsModule, PageShellComponent, AlertComponent,\n ButtonComponent, FormFieldComponent, TextInputComponent,\n ],\n template: `\n \n \n Uw huidige registratie verloopt op 1 september 2027. Vraag tijdig herregistratie aan.\n \n\n @if (submitted()) {\n
\n Uw aanvraag tot herregistratie is ontvangen.\n
\n } @else {\n
\n \n \n \n \n \n \n
\n Herregistratie aanvragen\n
\n
\n }\n
\n `,\n})\nexport class HerregistratiePage {\n uren = '';\n punten = '';\n urenError = signal('');\n submitted = signal(false);\n\n onSubmit() {\n if (!this.uren.trim()) { this.urenError.set('Vul het aantal gewerkte uren in.'); return; }\n this.urenError.set('');\n this.submitted.set(true);\n }\n}\n", "assetsDirs": [], "styleUrlsData": "", "stylesData": "", @@ -808,7 +1362,7 @@ }, { "name": "LoginFormComponent", - "id": "component-LoginFormComponent-da74a7fb3744fc3f15a9ce619398901ae87d71828357d6ac859efc66fac2a719775242f52a74b18094846c25f96708d539b2f0e42f50443076c041469e1b40c9", + "id": "component-LoginFormComponent-09b4fa4e3820031285372fc93c6a18697b3099647bf79b1869c466189d5041da442eaac2e437ae8efa5cd2546d72bbdbd9c001c24e8b1d26f363e763d0382e3d", "file": "src/app/organisms/login-form/login-form.component.ts", "encapsulation": [], "entryComponents": [], @@ -818,7 +1372,7 @@ "selector": "app-login-form", "styleUrls": [], "styles": [], - "template": "
\n Inloggen\n

Log in op uw persoonlijke BIG-register omgeving.

\n\n \n \n \n\n \n \n \n\n
\n Inloggen met DigiD\n
\n
\n", + "template": "
\n \n \n \n\n \n \n \n\n
\n Inloggen met DigiD\n
\n
\n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], @@ -832,7 +1386,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 34, + "line": 30, "required": false } ], @@ -846,7 +1400,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 32 + "line": 28 }, { "name": "password", @@ -857,7 +1411,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 33 + "line": 29 } ], "methodsClass": [], @@ -882,16 +1436,12 @@ { "name": "ButtonComponent", "type": "component" - }, - { - "name": "HeadingComponent", - "type": "component" } ], "description": "

Organism: DigiD-style mock login. No real auth — just composes atoms/molecules.

\n", "rawdescription": "\nOrganism: DigiD-style mock login. No real auth — just composes atoms/molecules.", "type": "component", - "sourceCode": "import { Component, output } from '@angular/core';\nimport { FormsModule } from '@angular/forms';\nimport { FormFieldComponent } from '../../molecules/form-field/form-field.component';\nimport { TextInputComponent } from '../../atoms/text-input/text-input.component';\nimport { ButtonComponent } from '../../atoms/button/button.component';\nimport { HeadingComponent } from '../../atoms/heading/heading.component';\n\n/** Organism: DigiD-style mock login. No real auth — just composes atoms/molecules. */\n@Component({\n selector: 'app-login-form',\n imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent, HeadingComponent],\n template: `\n
\n Inloggen\n

Log in op uw persoonlijke BIG-register omgeving.

\n\n \n \n \n\n \n \n \n\n
\n Inloggen met DigiD\n
\n
\n `,\n})\nexport class LoginFormComponent {\n bsn = '';\n password = '';\n submit = output();\n}\n", + "sourceCode": "import { Component, output } from '@angular/core';\nimport { FormsModule } from '@angular/forms';\nimport { FormFieldComponent } from '../../molecules/form-field/form-field.component';\nimport { TextInputComponent } from '../../atoms/text-input/text-input.component';\nimport { ButtonComponent } from '../../atoms/button/button.component';\n\n/** Organism: DigiD-style mock login. No real auth — just composes atoms/molecules. */\n@Component({\n selector: 'app-login-form',\n imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent],\n template: `\n
\n \n \n \n\n \n \n \n\n
\n Inloggen met DigiD\n
\n
\n `,\n})\nexport class LoginFormComponent {\n bsn = '';\n password = '';\n submit = output();\n}\n", "assetsDirs": [], "styleUrlsData": "", "stylesData": "", @@ -899,7 +1449,7 @@ }, { "name": "LoginPage", - "id": "component-LoginPage-5a16ef82d7e89717373df4c887dd02a573fb48c2529540e1a60b2205f7a971c4180f68fb069750bb9f704d1b50f04024f6ae5121e0dfff74fb7ccb5085a714c7", + "id": "component-LoginPage-e8a9fcb0309464188c46b54292e1b26314533e424703d8d6d84a1cffe31832e80d7ae8fe256da4b42d053ec775301d70502a9022e092ee0b2d10c7777baf1913", "file": "src/app/pages/login/login.page.ts", "encapsulation": [], "entryComponents": [], @@ -909,7 +1459,7 @@ "selector": "app-login-page", "styleUrls": [], "styles": [], - "template": "\n \n\n", + "template": "\n \n\n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], @@ -925,7 +1475,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 16, + "line": 17, "modifierKind": [ 123 ] @@ -938,7 +1488,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 17, + "line": 18, "deprecated": false, "deprecationMessage": "" } @@ -950,7 +1500,7 @@ "standalone": false, "imports": [ { - "name": "PageLayoutComponent", + "name": "PageShellComponent", "type": "component" }, { @@ -961,7 +1511,7 @@ "description": "", "rawdescription": "\n", "type": "component", - "sourceCode": "import { Component, inject } from '@angular/core';\nimport { Router } from '@angular/router';\nimport { PageLayoutComponent } from '../../templates/page-layout/page-layout.component';\nimport { LoginFormComponent } from '../../organisms/login-form/login-form.component';\n\n@Component({\n selector: 'app-login-page',\n imports: [PageLayoutComponent, LoginFormComponent],\n template: `\n \n \n \n `,\n})\nexport class LoginPage {\n private router = inject(Router);\n login() { this.router.navigate(['/dashboard']); }\n}\n", + "sourceCode": "import { Component, inject } from '@angular/core';\nimport { Router } from '@angular/router';\nimport { PageShellComponent } from '../../templates/page-shell/page-shell.component';\nimport { LoginFormComponent } from '../../organisms/login-form/login-form.component';\n\n@Component({\n selector: 'app-login-page',\n imports: [PageShellComponent, LoginFormComponent],\n template: `\n \n \n \n `,\n})\nexport class LoginPage {\n private router = inject(Router);\n login() { this.router.navigate(['/dashboard']); }\n}\n", "assetsDirs": [], "styleUrlsData": "", "stylesData": "", @@ -969,7 +1519,7 @@ }, { "name": "PageLayoutComponent", - "id": "component-PageLayoutComponent-784f601fe164e2ffc90b8d66eb224bb86616cc1928d5ffae5e954be4144bc9c13c3995cfb5185c472a833a8ef036b380c3cc47deb85b3393c5523eadfb9fdf4c", + "id": "component-PageLayoutComponent-dd10c533ec0fae79341ba928861681e8d5b84e8856a5d98644f7cfa8bddacd9059014a17899fb35eb34256d29d46fe17910e7541f7c227f82ebd04bd7ab00e8e", "file": "src/app/templates/page-layout/page-layout.component.ts", "encapsulation": [], "entryComponents": [], @@ -978,8 +1528,10 @@ "providers": [], "selector": "app-page-layout", "styleUrls": [], - "styles": [], - "template": "Naar de inhoud\n
\n \n
\n \n
\n \n
\n", + "styles": [ + ":host{display:block}" + ], + "template": "Naar de inhoud\n\n
\n \n
\n
\n \n
\n
\n \n
\n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], @@ -1002,18 +1554,125 @@ "type": "component" } ], - "description": "

Template: header + centered content + footer. Wraps every page.

\n", - "rawdescription": "\nTemplate: header + centered content + footer. Wraps every page.", + "description": "

Template: full-bleed header + footer with a centered content column. Wraps\nevery page. The colored bars span the viewport; content lines up via the\nshared --app-content-max width.

\n", + "rawdescription": "\nTemplate: full-bleed header + footer with a centered content column. Wraps\nevery page. The colored bars span the viewport; content lines up via the\nshared --app-content-max width.", "type": "component", - "sourceCode": "import { Component } from '@angular/core';\nimport { SiteHeaderComponent } from '../../organisms/site-header/site-header.component';\nimport { SiteFooterComponent } from '../../organisms/site-footer/site-footer.component';\n\n/** Template: header + centered content + footer. Wraps every page. */\n@Component({\n selector: 'app-page-layout',\n imports: [SiteHeaderComponent, SiteFooterComponent],\n template: `\n Naar de inhoud\n
\n \n
\n \n
\n \n
\n `,\n})\nexport class PageLayoutComponent {}\n", + "sourceCode": "import { Component } from '@angular/core';\nimport { SiteHeaderComponent } from '../../organisms/site-header/site-header.component';\nimport { SiteFooterComponent } from '../../organisms/site-footer/site-footer.component';\n\n/** Template: full-bleed header + footer with a centered content column. Wraps\n every page. The colored bars span the viewport; content lines up via the\n shared --app-content-max width. */\n@Component({\n selector: 'app-page-layout',\n imports: [SiteHeaderComponent, SiteFooterComponent],\n styles: [':host{display:block}'],\n template: `\n Naar de inhoud\n \n
\n \n
\n
\n \n
\n
\n \n
\n `,\n})\nexport class PageLayoutComponent {}\n", "assetsDirs": [], "styleUrlsData": "", - "stylesData": "", + "stylesData": ":host{display:block}\n", + "extends": [] + }, + { + "name": "PageShellComponent", + "id": "component-PageShellComponent-52728311f7328b4b0c4553918adf0b60fbfc082e4d7fcc85eb5375153b4231b4c11f8e83bb57b8bc14b0035921e4415ef241d802f7cf09e82557cef871c83623", + "file": "src/app/templates/page-shell/page-shell.component.ts", + "encapsulation": [], + "entryComponents": [], + "inputs": [], + "outputs": [], + "providers": [], + "selector": "app-page-shell", + "styleUrls": [], + "styles": [ + ":host{display:block}" + ], + "template": "\n
\n @if (backLink()) {\n

← {{ backLabel() }}

\n }\n {{ heading() }}\n @if (intro()) {\n

{{ intro() }}

\n }\n \n
\n
\n", + "templateUrl": [], + "viewProviders": [], + "hostDirectives": [], + "inputsClass": [ + { + "name": "backLabel", + "defaultValue": "'Terug naar overzicht'", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 32, + "required": false + }, + { + "name": "backLink", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 31, + "required": false + }, + { + "name": "heading", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 29, + "required": true + }, + { + "name": "intro", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 30, + "required": false + }, + { + "name": "width", + "defaultValue": "'default'", + "deprecated": false, + "deprecationMessage": "", + "type": "\"default\" | \"narrow\"", + "indexKey": "", + "optional": false, + "description": "", + "line": 33, + "required": false + } + ], + "outputsClass": [], + "propertiesClass": [], + "methodsClass": [], + "deprecated": false, + "deprecationMessage": "", + "hostBindings": [], + "hostListeners": [], + "standalone": false, + "imports": [ + { + "name": "PageLayoutComponent", + "type": "component" + }, + { + "name": "HeadingComponent", + "type": "component" + }, + { + "name": "LinkComponent", + "type": "component" + } + ], + "description": "

Template: standard page body inside the chrome — optional back-link, a\nheading, optional intro, and projected content. Every content page plugs\ninto this, so the heading/back-link markup lives in one place.

\n", + "rawdescription": "\nTemplate: standard page body inside the chrome — optional back-link, a\nheading, optional intro, and projected content. Every content page plugs\ninto this, so the heading/back-link markup lives in one place.", + "type": "component", + "sourceCode": "import { Component, input } from '@angular/core';\nimport { PageLayoutComponent } from '../page-layout/page-layout.component';\nimport { HeadingComponent } from '../../atoms/heading/heading.component';\nimport { LinkComponent } from '../../atoms/link/link.component';\n\n/** Template: standard page body inside the chrome — optional back-link, a\n heading, optional intro, and projected content. Every content page plugs\n into this, so the heading/back-link markup lives in one place. */\n@Component({\n selector: 'app-page-shell',\n imports: [PageLayoutComponent, HeadingComponent, LinkComponent],\n styles: [':host{display:block}'],\n template: `\n \n
\n @if (backLink()) {\n

← {{ backLabel() }}

\n }\n {{ heading() }}\n @if (intro()) {\n

{{ intro() }}

\n }\n \n
\n
\n `,\n})\nexport class PageShellComponent {\n heading = input.required();\n intro = input();\n backLink = input();\n backLabel = input('Terug naar overzicht');\n width = input<'default' | 'narrow'>('default');\n}\n", + "assetsDirs": [], + "styleUrlsData": "", + "stylesData": ":host{display:block}\n", "extends": [] }, { "name": "RegistrationDetailPage", - "id": "component-RegistrationDetailPage-83fe905f772b92eb3dfe113706becf0888d5e66b0af5376d769f8aeac2e8c41152512488fdff2a42cf0a4c3049840684e9b5872e6e380e355d7a6adc2b425508", + "id": "component-RegistrationDetailPage-efb92f879b64c25c80211fcc675636fad2b1709613792834a68cec3b82982d290d7f04e97dd3186900b77a5922cd219f565fbf9a9957ec78e2321ad7e2eb30dc", "file": "src/app/pages/registration-detail/registration-detail.page.ts", "encapsulation": [], "entryComponents": [], @@ -1023,7 +1682,7 @@ "selector": "app-registration-detail-page", "styleUrls": [], "styles": [], - "template": "\n

← Terug naar overzicht

\n Mijn gegevens\n\n @if (reg$ | async; as reg) {\n \n }\n\n
\n @if (submitted()) {\n Uw adreswijziging is ontvangen. U ontvangt binnen 5 werkdagen bericht.\n } @else {\n \n }\n
\n
\n", + "template": "\n \n \n \n \n \n \n \n \n\n
\n @if (submitted()) {\n Uw adreswijziging is ontvangen. U ontvangt binnen 5 werkdagen bericht.\n } @else {\n \n }\n
\n
\n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], @@ -1031,15 +1690,26 @@ "outputsClass": [], "propertiesClass": [ { - "name": "reg$", - "defaultValue": "this.svc.getRegistration()", + "name": "reg", + "defaultValue": "this.svc.registrationResource()", "deprecated": false, "deprecationMessage": "", "type": "unknown", "indexKey": "", "optional": false, "description": "", - "line": 35 + "line": 39 + }, + { + "name": "regEmpty", + "defaultValue": "() => {...}", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 40 }, { "name": "submitted", @@ -1050,7 +1720,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 36 + "line": 41 }, { "name": "svc", @@ -1061,7 +1731,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 34, + "line": 38, "modifierKind": [ 123 ] @@ -1075,25 +1745,20 @@ "standalone": false, "imports": [ { - "name": "AsyncPipe", - "type": "pipe" - }, - { - "name": "PageLayoutComponent", - "type": "component" - }, - { - "name": "HeadingComponent", - "type": "component" - }, - { - "name": "LinkComponent", + "name": "PageShellComponent", "type": "component" }, { "name": "AlertComponent", "type": "component" }, + { + "name": "SkeletonComponent", + "type": "component" + }, + { + "name": "ASYNC" + }, { "name": "RegistrationSummaryComponent", "type": "component" @@ -1106,7 +1771,7 @@ "description": "", "rawdescription": "\n", "type": "component", - "sourceCode": "import { Component, inject, signal } from '@angular/core';\nimport { AsyncPipe } from '@angular/common';\nimport { PageLayoutComponent } from '../../templates/page-layout/page-layout.component';\nimport { HeadingComponent } from '../../atoms/heading/heading.component';\nimport { LinkComponent } from '../../atoms/link/link.component';\nimport { AlertComponent } from '../../atoms/alert/alert.component';\nimport { RegistrationSummaryComponent } from '../../organisms/registration-summary/registration-summary.component';\nimport { ChangeRequestFormComponent } from '../../organisms/change-request-form/change-request-form.component';\nimport { RegistrationService } from '../../core/registration.service';\n\n@Component({\n selector: 'app-registration-detail-page',\n imports: [AsyncPipe, PageLayoutComponent, HeadingComponent, LinkComponent, AlertComponent, RegistrationSummaryComponent, ChangeRequestFormComponent],\n template: `\n \n

← Terug naar overzicht

\n Mijn gegevens\n\n @if (reg$ | async; as reg) {\n \n }\n\n
\n @if (submitted()) {\n Uw adreswijziging is ontvangen. U ontvangt binnen 5 werkdagen bericht.\n } @else {\n \n }\n
\n
\n `,\n})\nexport class RegistrationDetailPage {\n private svc = inject(RegistrationService);\n reg$ = this.svc.getRegistration();\n submitted = signal(false);\n}\n", + "sourceCode": "import { Component, inject, signal } from '@angular/core';\nimport { PageShellComponent } from '../../templates/page-shell/page-shell.component';\nimport { AlertComponent } from '../../atoms/alert/alert.component';\nimport { SkeletonComponent } from '../../atoms/skeleton/skeleton.component';\nimport { ASYNC } from '../../molecules/async/async.component';\nimport { RegistrationSummaryComponent } from '../../organisms/registration-summary/registration-summary.component';\nimport { ChangeRequestFormComponent } from '../../organisms/change-request-form/change-request-form.component';\nimport { RegistrationService } from '../../core/registration.service';\n\n@Component({\n selector: 'app-registration-detail-page',\n imports: [\n PageShellComponent, AlertComponent, SkeletonComponent, ...ASYNC,\n RegistrationSummaryComponent, ChangeRequestFormComponent,\n ],\n template: `\n \n \n \n \n \n \n \n \n \n\n
\n @if (submitted()) {\n Uw adreswijziging is ontvangen. U ontvangt binnen 5 werkdagen bericht.\n } @else {\n \n }\n
\n
\n `,\n})\nexport class RegistrationDetailPage {\n private svc = inject(RegistrationService);\n reg = this.svc.registrationResource();\n regEmpty = (v: unknown) => !v || Object.keys(v).length === 0;\n submitted = signal(false);\n}\n", "assetsDirs": [], "styleUrlsData": "", "stylesData": "", @@ -1226,7 +1891,7 @@ }, { "name": "SiteFooterComponent", - "id": "component-SiteFooterComponent-dc3de1b2d9be1f7a72a4e3a9fd3142ebdb33517cce05255f998242a83990eaf63b332a255e230f3731501ba731180500ce3d11212f17eb6a1dda6f57ac694ef6", + "id": "component-SiteFooterComponent-90391287c6e8f3ad80c034a7a6fdabdd0514fd9c0dcf549a8e4a2d3bc3d885fcd5a45a2eeeb24fa8a4bbcf2cfe865093d712765d9d1ed11b4bfca26d1fb35153", "file": "src/app/organisms/site-footer/site-footer.component.ts", "encapsulation": [], "entryComponents": [], @@ -1235,8 +1900,10 @@ "providers": [], "selector": "app-site-footer", "styleUrls": [], - "styles": [], - "template": "
\n
\n BIG-register\n CIBG — Ministerie van Volksgezondheid, Welzijn en Sport\n Demo / POC — geen echte gegevens\n
\n
\n", + "styles": [ + ":host{display:block}" + ], + "template": "
\n
\n BIG-register\n CIBG — Ministerie van Volksgezondheid, Welzijn en Sport\n Demo / POC — geen echte gegevens\n
\n
\n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], @@ -1253,15 +1920,15 @@ "description": "

Organism: site footer.

\n", "rawdescription": "\nOrganism: site footer.", "type": "component", - "sourceCode": "import { Component } from '@angular/core';\n\n/** Organism: site footer. */\n@Component({\n selector: 'app-site-footer',\n template: `\n
\n
\n BIG-register\n CIBG — Ministerie van Volksgezondheid, Welzijn en Sport\n Demo / POC — geen echte gegevens\n
\n
\n `,\n})\nexport class SiteFooterComponent {}\n", + "sourceCode": "import { Component } from '@angular/core';\n\n/** Organism: site footer. */\n@Component({\n selector: 'app-site-footer',\n styles: [':host{display:block}'],\n template: `\n
\n
\n BIG-register\n CIBG — Ministerie van Volksgezondheid, Welzijn en Sport\n Demo / POC — geen echte gegevens\n
\n
\n `,\n})\nexport class SiteFooterComponent {}\n", "assetsDirs": [], "styleUrlsData": "", - "stylesData": "", + "stylesData": ":host{display:block}\n", "extends": [] }, { "name": "SiteHeaderComponent", - "id": "component-SiteHeaderComponent-868e8e5af92fdf7923a2d989c5dcc13b193e2e09e186d8197937817cabc5bd13a676c11db35f4738ea9167b4425ad1fea6e7bd0b5f798983202b7bb99a37734a", + "id": "component-SiteHeaderComponent-710f1c109f26ae5f1888aa77ac682c58c9534fbba74aeae04b903e169f674c1ce04b8ae9e379a936c8ddef16e53c500e93d132021bcdd4ef743a601e05808f66", "file": "src/app/organisms/site-header/site-header.component.ts", "encapsulation": [], "entryComponents": [], @@ -1270,8 +1937,10 @@ "providers": [], "selector": "app-site-header", "styleUrls": [], - "styles": [], - "template": "
\n \n
\n", + "styles": [ + ":host{display:block}" + ], + "template": "
\n \n
\n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], @@ -1284,7 +1953,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 24, + "line": 27, "required": false } ], @@ -1304,15 +1973,266 @@ "description": "

Organism: site header with Rijksoverheid-style wordmark + title.\nponytail: text wordmark instead of the licensed Rijksoverheid logo.

\n", "rawdescription": "\nOrganism: site header with Rijksoverheid-style wordmark + title.\nponytail: text wordmark instead of the licensed Rijksoverheid logo.", "type": "component", - "sourceCode": "import { Component, input } from '@angular/core';\nimport { RouterLink } from '@angular/router';\n\n/** Organism: site header with Rijksoverheid-style wordmark + title.\n ponytail: text wordmark instead of the licensed Rijksoverheid logo. */\n@Component({\n selector: 'app-site-header',\n imports: [RouterLink],\n template: `\n
\n \n
\n `,\n})\nexport class SiteHeaderComponent {\n subtitle = input('Mijn omgeving');\n}\n", + "sourceCode": "import { Component, input } from '@angular/core';\nimport { RouterLink } from '@angular/router';\n\n/** Organism: site header with Rijksoverheid-style wordmark + title.\n ponytail: text wordmark instead of the licensed Rijksoverheid logo. */\n@Component({\n selector: 'app-site-header',\n imports: [RouterLink],\n // :host display:block so the full-bleed bar fills the flex column (custom\n // elements default to display:inline, which collapsed the bar to its content).\n styles: [':host{display:block}'],\n template: `\n
\n \n
\n `,\n})\nexport class SiteHeaderComponent {\n subtitle = input('Mijn omgeving');\n}\n", "assetsDirs": [], "styleUrlsData": "", - "stylesData": "", + "stylesData": ":host{display:block}\n", "extends": [] }, + { + "name": "SkeletonComponent", + "id": "component-SkeletonComponent-608d7384a7cd5705153c042fd961a63d69e4f61409276c2aec354b33db4199192d161fa81305090c262e18ab0a019913f469e85603ccefa6fba5c0dbc40d4f72", + "file": "src/app/atoms/skeleton/skeleton.component.ts", + "encapsulation": [], + "entryComponents": [], + "inputs": [], + "outputs": [], + "providers": [], + "selector": "app-skeleton", + "styleUrls": [], + "styles": [ + "\n :host{display:block}\n .sk{background:linear-gradient(90deg,#e8ebee 25%,#f3f5f6 37%,#e8ebee 63%);\n background-size:400% 100%;animation:sh 1.4s ease infinite;border-radius:4px;\n margin-block-end:0.6rem}\n @keyframes sh{0%{background-position:100% 0}100%{background-position:0 0}}\n " + ], + "template": "@if (visible()) {\n @for (l of lines(); track $index) {\n
\n }\n}\n", + "templateUrl": [], + "viewProviders": [], + "hostDirectives": [], + "inputsClass": [ + { + "name": "count", + "defaultValue": "1", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 25, + "required": false + }, + { + "name": "delay", + "defaultValue": "150", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 26, + "required": false + }, + { + "name": "height", + "defaultValue": "'1rem'", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 24, + "required": false + }, + { + "name": "width", + "defaultValue": "'100%'", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 23, + "required": false + } + ], + "outputsClass": [], + "propertiesClass": [ + { + "name": "lines", + "defaultValue": "computed(() => Array(this.count()).fill(0))", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 28, + "modifierKind": [ + 124 + ] + }, + { + "name": "timer", + "deprecated": false, + "deprecationMessage": "", + "type": "ReturnType", + "indexKey": "", + "optional": true, + "description": "", + "line": 29, + "modifierKind": [ + 123 + ] + }, + { + "name": "visible", + "defaultValue": "signal(false)", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 27, + "modifierKind": [ + 124 + ] + } + ], + "methodsClass": [ + { + "name": "ngOnDestroy", + "args": [], + "optional": false, + "returnType": "void", + "typeParameters": [], + "line": 32, + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "ngOnInit", + "args": [], + "optional": false, + "returnType": "void", + "typeParameters": [], + "line": 31, + "deprecated": false, + "deprecationMessage": "" + } + ], + "deprecated": false, + "deprecationMessage": "", + "hostBindings": [], + "hostListeners": [], + "standalone": false, + "imports": [], + "description": "

Atom: skeleton placeholder (grey shimmer). Delay-gated so it never flashes\non fast responses. Render count lines shaped roughly like the content.

\n", + "rawdescription": "\nAtom: skeleton placeholder (grey shimmer). Delay-gated so it never flashes\non fast responses. Render `count` lines shaped roughly like the content.", + "type": "component", + "sourceCode": "import { Component, OnDestroy, OnInit, computed, input, signal } from '@angular/core';\n\n/** Atom: skeleton placeholder (grey shimmer). Delay-gated so it never flashes\n on fast responses. Render `count` lines shaped roughly like the content. */\n@Component({\n selector: 'app-skeleton',\n styles: [`\n :host{display:block}\n .sk{background:linear-gradient(90deg,#e8ebee 25%,#f3f5f6 37%,#e8ebee 63%);\n background-size:400% 100%;animation:sh 1.4s ease infinite;border-radius:4px;\n margin-block-end:0.6rem}\n @keyframes sh{0%{background-position:100% 0}100%{background-position:0 0}}\n `],\n template: `\n @if (visible()) {\n @for (l of lines(); track $index) {\n
\n }\n }\n `,\n})\nexport class SkeletonComponent implements OnInit, OnDestroy {\n width = input('100%');\n height = input('1rem');\n count = input(1);\n delay = input(150);\n protected visible = signal(false);\n protected lines = computed(() => Array(this.count()).fill(0));\n private timer?: ReturnType;\n\n ngOnInit() { this.timer = setTimeout(() => this.visible.set(true), this.delay()); }\n ngOnDestroy() { clearTimeout(this.timer); }\n}\n", + "assetsDirs": [], + "styleUrlsData": "", + "stylesData": "\n :host{display:block}\n .sk{background:linear-gradient(90deg,#e8ebee 25%,#f3f5f6 37%,#e8ebee 63%);\n background-size:400% 100%;animation:sh 1.4s ease infinite;border-radius:4px;\n margin-block-end:0.6rem}\n @keyframes sh{0%{background-position:100% 0}100%{background-position:0 0}}\n \n", + "extends": [], + "implements": [ + "OnInit", + "OnDestroy" + ] + }, + { + "name": "SpinnerComponent", + "id": "component-SpinnerComponent-4ac83777af1737425f1eec0d4c22a830297cca57c3140ef014842789163b2c2873312a9ae4e9dc7554241ac3b3dc339b7bab22e02fd8a4fe748a985264126ca4", + "file": "src/app/atoms/spinner/spinner.component.ts", + "encapsulation": [], + "entryComponents": [], + "inputs": [], + "outputs": [], + "providers": [], + "selector": "app-spinner", + "styleUrls": [], + "styles": [ + "\n :host{display:block}\n .sp{width:2rem;height:2rem;border-radius:50%;\n border:3px solid var(--rhc-color-grijs-300,#cad0d6);\n border-block-start-color:var(--rhc-color-lintblauw-700,#154273);\n animation:sp 0.8s linear infinite;margin:1.5rem auto}\n @keyframes sp{to{transform:rotate(360deg)}}\n " + ], + "template": "@if (visible()) {\n
\n}\n", + "templateUrl": [], + "viewProviders": [], + "hostDirectives": [], + "inputsClass": [ + { + "name": "delay", + "defaultValue": "250", + "deprecated": false, + "deprecationMessage": "", + "indexKey": "", + "optional": false, + "description": "", + "line": 22, + "required": false + } + ], + "outputsClass": [], + "propertiesClass": [ + { + "name": "timer", + "deprecated": false, + "deprecationMessage": "", + "type": "ReturnType", + "indexKey": "", + "optional": true, + "description": "", + "line": 24, + "modifierKind": [ + 123 + ] + }, + { + "name": "visible", + "defaultValue": "signal(false)", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 23, + "modifierKind": [ + 124 + ] + } + ], + "methodsClass": [ + { + "name": "ngOnDestroy", + "args": [], + "optional": false, + "returnType": "void", + "typeParameters": [], + "line": 27, + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "ngOnInit", + "args": [], + "optional": false, + "returnType": "void", + "typeParameters": [], + "line": 26, + "deprecated": false, + "deprecationMessage": "" + } + ], + "deprecated": false, + "deprecationMessage": "", + "hostBindings": [], + "hostListeners": [], + "standalone": false, + "imports": [], + "description": "

Atom: spinner that only appears after delay ms — fast responses never\nflash a spinner, slow ones get feedback.

\n", + "rawdescription": "\nAtom: spinner that only appears after `delay` ms — fast responses never\nflash a spinner, slow ones get feedback.", + "type": "component", + "sourceCode": "import { Component, OnDestroy, OnInit, input, signal } from '@angular/core';\n\n/** Atom: spinner that only appears after `delay` ms — fast responses never\n flash a spinner, slow ones get feedback. */\n@Component({\n selector: 'app-spinner',\n styles: [`\n :host{display:block}\n .sp{width:2rem;height:2rem;border-radius:50%;\n border:3px solid var(--rhc-color-grijs-300,#cad0d6);\n border-block-start-color:var(--rhc-color-lintblauw-700,#154273);\n animation:sp 0.8s linear infinite;margin:1.5rem auto}\n @keyframes sp{to{transform:rotate(360deg)}}\n `],\n template: `\n @if (visible()) {\n
\n }\n `,\n})\nexport class SpinnerComponent implements OnInit, OnDestroy {\n delay = input(250);\n protected visible = signal(false);\n private timer?: ReturnType;\n\n ngOnInit() { this.timer = setTimeout(() => this.visible.set(true), this.delay()); }\n ngOnDestroy() { clearTimeout(this.timer); }\n}\n", + "assetsDirs": [], + "styleUrlsData": "", + "stylesData": "\n :host{display:block}\n .sp{width:2rem;height:2rem;border-radius:50%;\n border:3px solid var(--rhc-color-grijs-300,#cad0d6);\n border-block-start-color:var(--rhc-color-lintblauw-700,#154273);\n animation:sp 0.8s linear infinite;margin:1.5rem auto}\n @keyframes sp{to{transform:rotate(360deg)}}\n \n", + "extends": [], + "implements": [ + "OnInit", + "OnDestroy" + ] + }, { "name": "StatusBadgeComponent", - "id": "component-StatusBadgeComponent-e270c2a0e9f8ec765c9d423e1c4da0d6382ccb17085bddde9037a26a5886b83b2d829f8b4c21c47ffa068508c621ba511b4fd5094c8784fffc43edc781ce95d0", + "id": "component-StatusBadgeComponent-3374b85f056b7a50e5cce348202733fea8109c0244004ec59ac829d32a629dbd750f1a087eabbdad5fce0aba1cf9495090e7a949f0bec2cdc371f854fc77d365", "file": "src/app/atoms/status-badge/status-badge.component.ts", "encapsulation": [], "entryComponents": [], @@ -1322,7 +2242,7 @@ "selector": "app-status-badge", "styleUrls": [], "styles": [], - "template": "\n \n {{ status() }}\n\n", + "template": "\n \n {{ status() }}\n\n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], @@ -1363,7 +2283,7 @@ "description": "

Atom: status badge = colored RHC dot-badge + label. Hand-built to show how\nyou compose a new atom from design tokens (dot color driven per status).

\n", "rawdescription": "\nAtom: status badge = colored RHC dot-badge + label. Hand-built to show how\nyou compose a new atom from design tokens (dot color driven per status).", "type": "component", - "sourceCode": "import { Component, computed, input } from '@angular/core';\nimport { RegistrationStatus } from '../../core/models';\n\n/** Atom: status badge = colored RHC dot-badge + label. Hand-built to show how\n you compose a new atom from design tokens (dot color driven per status). */\n@Component({\n selector: 'app-status-badge',\n template: `\n \n \n {{ status() }}\n \n `,\n})\nexport class StatusBadgeComponent {\n status = input.required();\n color = computed(() => ({\n Geregistreerd: 'var(--rhc-color-groen-500)',\n Doorgehaald: 'var(--rhc-color-rood-500)',\n Geschorst: 'var(--rhc-color-oranje-500)',\n }[this.status()]));\n}\n", + "sourceCode": "import { Component, computed, input } from '@angular/core';\nimport { RegistrationStatus } from '../../core/models';\n\n/** Atom: status badge = colored RHC dot-badge + label. Hand-built to show how\n you compose a new atom from design tokens (dot color driven per status). */\n@Component({\n selector: 'app-status-badge',\n template: `\n \n \n {{ status() }}\n \n `,\n})\nexport class StatusBadgeComponent {\n status = input.required();\n color = computed(() => ({\n Geregistreerd: 'var(--rhc-color-groen-500)',\n Doorgehaald: 'var(--rhc-color-rood-500)',\n Geschorst: 'var(--rhc-color-oranje-500)',\n }[this.status()]));\n}\n", "assetsDirs": [], "styleUrlsData": "", "stylesData": "", @@ -1697,7 +2617,19 @@ "deprecated": false, "deprecationMessage": "", "type": "ApplicationConfig", - "defaultValue": "{\n providers: [\n provideBrowserGlobalErrorListeners(),\n provideRouter(routes),\n provideHttpClient(),\n { provide: LOCALE_ID, useValue: 'nl' },\n ]\n}" + "defaultValue": "{\n providers: [\n provideBrowserGlobalErrorListeners(),\n provideRouter(routes),\n provideHttpClient(withInterceptors([scenarioInterceptor])),\n { provide: LOCALE_ID, useValue: 'nl' },\n ]\n}" + }, + { + "name": "ASYNC", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/molecules/async/async.component.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "defaultValue": "[\n AsyncComponent, AsyncLoadedDirective, AsyncLoadingDirective,\n AsyncEmptyDirective, AsyncErrorDirective,\n] as const", + "rawdescription": "Convenience: import this array to get the wrapper + all slot directives.", + "description": "

Convenience: import this array to get the wrapper + all slot directives.

\n" }, { "name": "routes", @@ -1707,10 +2639,44 @@ "deprecated": false, "deprecationMessage": "", "type": "Routes", - "defaultValue": "[\n { path: '', pathMatch: 'full', redirectTo: 'login' },\n { path: 'login', loadComponent: () => \"import('./pages/login/login.page').then(m => m.LoginPage)\" },\n { path: 'dashboard', loadComponent: () => \"import('./pages/dashboard/dashboard.page').then(m => m.DashboardPage)\" },\n { path: 'registratie', loadComponent: () => \"import('./pages/registration-detail/registration-detail.page').then(m => m.RegistrationDetailPage)\" },\n { path: '**', redirectTo: 'login' },\n]" + "defaultValue": "[\n { path: '', pathMatch: 'full', redirectTo: 'login' },\n { path: 'login', loadComponent: () => \"import('./pages/login/login.page').then(m => m.LoginPage)\" },\n { path: 'dashboard', loadComponent: () => \"import('./pages/dashboard/dashboard.page').then(m => m.DashboardPage)\" },\n { path: 'registratie', loadComponent: () => \"import('./pages/registration-detail/registration-detail.page').then(m => m.RegistrationDetailPage)\" },\n { path: 'herregistratie', loadComponent: () => \"import('./pages/herregistratie/herregistratie.page').then(m => m.HerregistratiePage)\" },\n { path: '**', redirectTo: 'login' },\n]" + }, + { + "name": "scenarioInterceptor", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/core/scenario.interceptor.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "HttpInterceptorFn", + "defaultValue": "(req, next) => {\n if (!req.url.includes('mock/')) return next(req);\n\n switch (currentScenario()) {\n case 'slow':\n return next(req).pipe(delay(2500));\n case 'loading':\n return next(req).pipe(delay(600_000)); // effectively never resolves\n case 'empty':\n return of(new HttpResponse({ status: 200, body: [] })).pipe(delay(400));\n case 'error':\n return timer(400).pipe(\n switchMap(() => throwError(() =>\n new HttpErrorResponse({ status: 500, statusText: 'Demo-fout', url: req.url }))),\n );\n default:\n return next(req);\n }\n}", + "rawdescription": "Demo-only: rewrites the timing/outcome of mock data requests based on\n?scenario= so loading / empty / error states can be shown on demand.\nReal requests are untouched.", + "description": "

Demo-only: rewrites the timing/outcome of mock data requests based on\n?scenario= so loading / empty / error states can be shown on demand.\nReal requests are untouched.

\n" + }, + { + "name": "VALID", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/core/scenario.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "Scenario[]", + "defaultValue": "['default', 'slow', 'loading', 'empty', 'error']" + } + ], + "functions": [ + { + "name": "currentScenario", + "file": "src/app/core/scenario.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Reads ?scenario= from the URL so a demo can force each async state.

\n", + "args": [], + "returnType": "Scenario" } ], - "functions": [], "typealiases": [ { "name": "AlertType", @@ -1734,6 +2700,28 @@ "description": "", "kind": 193 }, + { + "name": "Scenario", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "\"default\" | \"slow\" | \"loading\" | \"empty\" | \"error\"", + "file": "src/app/core/scenario.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "kind": 193 + }, + { + "name": "State", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "\"loading\" | \"error\" | \"empty\" | \"loaded\"", + "file": "src/app/molecules/async/async.component.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "kind": 193 + }, { "name": "Variant", "ctype": "miscellaneous", @@ -1757,7 +2745,21 @@ "deprecated": false, "deprecationMessage": "", "type": "ApplicationConfig", - "defaultValue": "{\n providers: [\n provideBrowserGlobalErrorListeners(),\n provideRouter(routes),\n provideHttpClient(),\n { provide: LOCALE_ID, useValue: 'nl' },\n ]\n}" + "defaultValue": "{\n providers: [\n provideBrowserGlobalErrorListeners(),\n provideRouter(routes),\n provideHttpClient(withInterceptors([scenarioInterceptor])),\n { provide: LOCALE_ID, useValue: 'nl' },\n ]\n}" + } + ], + "src/app/molecules/async/async.component.ts": [ + { + "name": "ASYNC", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/molecules/async/async.component.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "defaultValue": "[\n AsyncComponent, AsyncLoadedDirective, AsyncLoadingDirective,\n AsyncEmptyDirective, AsyncErrorDirective,\n] as const", + "rawdescription": "Convenience: import this array to get the wrapper + all slot directives.", + "description": "

Convenience: import this array to get the wrapper + all slot directives.

\n" } ], "src/app/app.routes.ts": [ @@ -1769,11 +2771,51 @@ "deprecated": false, "deprecationMessage": "", "type": "Routes", - "defaultValue": "[\n { path: '', pathMatch: 'full', redirectTo: 'login' },\n { path: 'login', loadComponent: () => \"import('./pages/login/login.page').then(m => m.LoginPage)\" },\n { path: 'dashboard', loadComponent: () => \"import('./pages/dashboard/dashboard.page').then(m => m.DashboardPage)\" },\n { path: 'registratie', loadComponent: () => \"import('./pages/registration-detail/registration-detail.page').then(m => m.RegistrationDetailPage)\" },\n { path: '**', redirectTo: 'login' },\n]" + "defaultValue": "[\n { path: '', pathMatch: 'full', redirectTo: 'login' },\n { path: 'login', loadComponent: () => \"import('./pages/login/login.page').then(m => m.LoginPage)\" },\n { path: 'dashboard', loadComponent: () => \"import('./pages/dashboard/dashboard.page').then(m => m.DashboardPage)\" },\n { path: 'registratie', loadComponent: () => \"import('./pages/registration-detail/registration-detail.page').then(m => m.RegistrationDetailPage)\" },\n { path: 'herregistratie', loadComponent: () => \"import('./pages/herregistratie/herregistratie.page').then(m => m.HerregistratiePage)\" },\n { path: '**', redirectTo: 'login' },\n]" + } + ], + "src/app/core/scenario.interceptor.ts": [ + { + "name": "scenarioInterceptor", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/core/scenario.interceptor.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "HttpInterceptorFn", + "defaultValue": "(req, next) => {\n if (!req.url.includes('mock/')) return next(req);\n\n switch (currentScenario()) {\n case 'slow':\n return next(req).pipe(delay(2500));\n case 'loading':\n return next(req).pipe(delay(600_000)); // effectively never resolves\n case 'empty':\n return of(new HttpResponse({ status: 200, body: [] })).pipe(delay(400));\n case 'error':\n return timer(400).pipe(\n switchMap(() => throwError(() =>\n new HttpErrorResponse({ status: 500, statusText: 'Demo-fout', url: req.url }))),\n );\n default:\n return next(req);\n }\n}", + "rawdescription": "Demo-only: rewrites the timing/outcome of mock data requests based on\n?scenario= so loading / empty / error states can be shown on demand.\nReal requests are untouched.", + "description": "

Demo-only: rewrites the timing/outcome of mock data requests based on\n?scenario= so loading / empty / error states can be shown on demand.\nReal requests are untouched.

\n" + } + ], + "src/app/core/scenario.ts": [ + { + "name": "VALID", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/core/scenario.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "Scenario[]", + "defaultValue": "['default', 'slow', 'loading', 'empty', 'error']" + } + ] + }, + "groupedFunctions": { + "src/app/core/scenario.ts": [ + { + "name": "currentScenario", + "file": "src/app/core/scenario.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Reads ?scenario= from the URL so a demo can force each async state.

\n", + "args": [], + "returnType": "Scenario" } ] }, - "groupedFunctions": {}, "groupedEnumerations": {}, "groupedTypeAliases": { "src/app/atoms/alert/alert.component.ts": [ @@ -1802,6 +2844,32 @@ "kind": 193 } ], + "src/app/core/scenario.ts": [ + { + "name": "Scenario", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "\"default\" | \"slow\" | \"loading\" | \"empty\" | \"error\"", + "file": "src/app/core/scenario.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "kind": 193 + } + ], + "src/app/molecules/async/async.component.ts": [ + { + "name": "State", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "\"loading\" | \"error\" | \"empty\" | \"loaded\"", + "file": "src/app/molecules/async/async.component.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "kind": 193 + } + ], "src/app/atoms/button/button.component.ts": [ { "name": "Variant", @@ -1836,6 +2904,11 @@ "kind": "route-path", "filename": "src/app/app.routes.ts" }, + { + "name": "herregistratie", + "kind": "route-path", + "filename": "src/app/app.routes.ts" + }, { "name": "**", "kind": "route-path", @@ -1854,7 +2927,7 @@ ] }, "coverage": { - "count": 24, + "count": 25, "status": "low", "files": [ { @@ -1942,6 +3015,24 @@ "coverageCount": "1/2", "status": "medium" }, + { + "filePath": "src/app/atoms/skeleton/skeleton.component.ts", + "type": "component", + "linktype": "component", + "name": "SkeletonComponent", + "coveragePercent": 10, + "coverageCount": "1/10", + "status": "low" + }, + { + "filePath": "src/app/atoms/spinner/spinner.component.ts", + "type": "component", + "linktype": "component", + "name": "SpinnerComponent", + "coveragePercent": 16, + "coverageCount": "1/6", + "status": "low" + }, { "filePath": "src/app/atoms/status-badge/status-badge.component.ts", "type": "component", @@ -1993,8 +3084,113 @@ "type": "injectable", "linktype": "injectable", "name": "RegistrationService", + "coveragePercent": 33, + "coverageCount": "1/3", + "status": "medium" + }, + { + "filePath": "src/app/core/scenario.interceptor.ts", + "type": "variable", + "linktype": "miscellaneous", + "linksubtype": "variable", + "name": "scenarioInterceptor", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/core/scenario.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "currentScenario", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/core/scenario.ts", + "type": "variable", + "linktype": "miscellaneous", + "linksubtype": "variable", + "name": "VALID", "coveragePercent": 0, - "coverageCount": "0/4", + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/core/scenario.ts", + "type": "type alias", + "linktype": "miscellaneous", + "linksubtype": "typealias", + "name": "Scenario", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/molecules/async/async.component.ts", + "type": "component", + "linktype": "component", + "name": "AsyncComponent", + "coveragePercent": 10, + "coverageCount": "1/10", + "status": "low" + }, + { + "filePath": "src/app/molecules/async/async.component.ts", + "type": "directive", + "linktype": "directive", + "name": "AsyncEmptyDirective", + "coveragePercent": 0, + "coverageCount": "0/3", + "status": "low" + }, + { + "filePath": "src/app/molecules/async/async.component.ts", + "type": "directive", + "linktype": "directive", + "name": "AsyncErrorDirective", + "coveragePercent": 0, + "coverageCount": "0/3", + "status": "low" + }, + { + "filePath": "src/app/molecules/async/async.component.ts", + "type": "directive", + "linktype": "directive", + "name": "AsyncLoadedDirective", + "coveragePercent": 0, + "coverageCount": "0/3", + "status": "low" + }, + { + "filePath": "src/app/molecules/async/async.component.ts", + "type": "directive", + "linktype": "directive", + "name": "AsyncLoadingDirective", + "coveragePercent": 0, + "coverageCount": "0/3", + "status": "low" + }, + { + "filePath": "src/app/molecules/async/async.component.ts", + "type": "variable", + "linktype": "miscellaneous", + "linksubtype": "variable", + "name": "ASYNC", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/molecules/async/async.component.ts", + "type": "type alias", + "linktype": "miscellaneous", + "linksubtype": "typealias", + "name": "State", + "coveragePercent": 0, + "coverageCount": "0/1", "status": "low" }, { @@ -2075,7 +3271,16 @@ "linktype": "component", "name": "DashboardPage", "coveragePercent": 0, - "coverageCount": "0/4", + "coverageCount": "0/6", + "status": "low" + }, + { + "filePath": "src/app/pages/herregistratie/herregistratie.page.ts", + "type": "component", + "linktype": "component", + "name": "HerregistratiePage", + "coveragePercent": 16, + "coverageCount": "1/6", "status": "low" }, { @@ -2093,7 +3298,7 @@ "linktype": "component", "name": "RegistrationDetailPage", "coveragePercent": 0, - "coverageCount": "0/4", + "coverageCount": "0/5", "status": "low" }, { @@ -2104,6 +3309,15 @@ "coveragePercent": 100, "coverageCount": "1/1", "status": "very-good" + }, + { + "filePath": "src/app/templates/page-shell/page-shell.component.ts", + "type": "component", + "linktype": "component", + "name": "PageShellComponent", + "coveragePercent": 16, + "coverageCount": "1/6", + "status": "low" } ] } diff --git a/src/app/app.config.ts b/src/app/app.config.ts index f8c6004..af4dfdb 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -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' }, ] }; diff --git a/src/app/atoms/skeleton/skeleton.component.ts b/src/app/atoms/skeleton/skeleton.component.ts new file mode 100644 index 0000000..a85fcda --- /dev/null +++ b/src/app/atoms/skeleton/skeleton.component.ts @@ -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) { +
+ } + } + `, +}) +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; + + ngOnInit() { this.timer = setTimeout(() => this.visible.set(true), this.delay()); } + ngOnDestroy() { clearTimeout(this.timer); } +} diff --git a/src/app/atoms/skeleton/skeleton.stories.ts b/src/app/atoms/skeleton/skeleton.stories.ts new file mode 100644 index 0000000..b27aaf0 --- /dev/null +++ b/src/app/atoms/skeleton/skeleton.stories.ts @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { SkeletonComponent } from './skeleton.component'; + +const meta: Meta = { + title: 'Atoms/Skeleton', + component: SkeletonComponent, + args: { delay: 0 }, +}; +export default meta; +type Story = StoryObj; + +export const SingleLine: Story = { args: { width: '60%', height: '1rem' } }; +export const CardPlaceholder: Story = { args: { height: '2.5rem', count: 6 } }; diff --git a/src/app/atoms/spinner/spinner.component.ts b/src/app/atoms/spinner/spinner.component.ts new file mode 100644 index 0000000..25101df --- /dev/null +++ b/src/app/atoms/spinner/spinner.component.ts @@ -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()) { +
+ } + `, +}) +export class SpinnerComponent implements OnInit, OnDestroy { + delay = input(250); + protected visible = signal(false); + private timer?: ReturnType; + + ngOnInit() { this.timer = setTimeout(() => this.visible.set(true), this.delay()); } + ngOnDestroy() { clearTimeout(this.timer); } +} diff --git a/src/app/atoms/spinner/spinner.stories.ts b/src/app/atoms/spinner/spinner.stories.ts new file mode 100644 index 0000000..7001729 --- /dev/null +++ b/src/app/atoms/spinner/spinner.stories.ts @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { SpinnerComponent } from './spinner.component'; + +const meta: Meta = { + title: 'Atoms/Spinner', + component: SpinnerComponent, +}; +export default meta; +type Story = StoryObj; + +// delay 0 so it shows immediately in the story +export const Default: Story = { args: { delay: 0 } }; diff --git a/src/app/core/registration.service.ts b/src/app/core/registration.service.ts index c6f172f..ffce9ef 100644 --- a/src/app/core/registration.service.ts +++ b/src/app/core/registration.service.ts @@ -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 , + * 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 { - return this.http.get('mock/registration.json'); + registrationResource() { + return httpResource(() => 'mock/registration.json'); } - getAantekeningen(): Observable { - return this.http.get('mock/notes.json'); + aantekeningenResource() { + return httpResource(() => 'mock/notes.json', { defaultValue: [] }); } } diff --git a/src/app/core/scenario.interceptor.ts b/src/app/core/scenario.interceptor.ts new file mode 100644 index 0000000..cc9cbe7 --- /dev/null +++ b/src/app/core/scenario.interceptor.ts @@ -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); + } +}; diff --git a/src/app/core/scenario.ts b/src/app/core/scenario.ts new file mode 100644 index 0000000..9bcd86b --- /dev/null +++ b/src/app/core/scenario.ts @@ -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'; +} diff --git a/src/app/molecules/async/async.component.ts b/src/app/molecules/async/async.component.ts new file mode 100644 index 0000000..ab7cac9 --- /dev/null +++ b/src/app/molecules/async/async.component.ts @@ -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 children of . */ +@Directive({ selector: '[appAsyncLoaded]' }) +export class AsyncLoadedDirective { + constructor(public tpl: TemplateRef<{ $implicit: unknown }>) {} +} +@Directive({ selector: '[appAsyncLoading]' }) +export class AsyncLoadingDirective { + constructor(public tpl: TemplateRef) {} +} +@Directive({ selector: '[appAsyncEmpty]' }) +export class AsyncEmptyDirective { + constructor(public tpl: TemplateRef) {} +} +@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()) { } + @else { } + } + @case ('error') { + @if (errorTpl()) { + + } @else { + Er ging iets mis bij het laden van de gegevens. +
+ Opnieuw proberen +
+ } + } + @case ('empty') { + @if (emptyTpl()) { } + @else {

Geen gegevens gevonden.

} + } + @case ('loaded') { + + } + } + `, +}) +export class AsyncComponent { + resource = input.required>(); + 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(() => { + 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; diff --git a/src/app/molecules/async/async.stories.ts b/src/app/molecules/async/async.stories.ts new file mode 100644 index 0000000..0932e56 --- /dev/null +++ b/src/app/molecules/async/async.stories.ts @@ -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(status: string, value?: T, error?: Error): Resource { + return { + value: () => value as T, + status: () => status, + error: () => error, + hasValue: () => value !== undefined, + reload: () => {}, + } as unknown as Resource; +} + +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: ` + + +
    + @for (i of items; track i) {
  • {{ i }}
  • } +
+
+ +

Geen items gevonden.

+
`, + }), +}; +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')) } }; diff --git a/src/app/organisms/login-form/login-form.component.ts b/src/app/organisms/login-form/login-form.component.ts index 5385e24..3cc7029 100644 --- a/src/app/organisms/login-form/login-form.component.ts +++ b/src/app/organisms/login-form/login-form.component.ts @@ -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: ` -
- Inloggen -

Log in op uw persoonlijke BIG-register omgeving.

- + diff --git a/src/app/organisms/site-footer/site-footer.component.ts b/src/app/organisms/site-footer/site-footer.component.ts index fbfb9a7..5467445 100644 --- a/src/app/organisms/site-footer/site-footer.component.ts +++ b/src/app/organisms/site-footer/site-footer.component.ts @@ -3,9 +3,10 @@ import { Component } from '@angular/core'; /** Organism: site footer. */ @Component({ selector: 'app-site-footer', + styles: [':host{display:block}'], template: ` -