refactor: split concepts.page into 6 sections, fix dead highlighting (RD-24)
The page held six teaching sections and a 142-line `styles:` block, at 471 effective lines against a limit of 250. It is now 36 lines of composition. Angular scopes a component's CSS to markup that component rendered, so the split had to move each rule to its owner. `concept-card` owns the card vocabulary and renders it. `.app-code`, `.app-lead`, `.app-cols` and `.app-note` become globals, because their targets are projected or arrive through `[innerHTML]`. That constraint exposed a live bug. The syntax-highlighting rules compiled to `pre[_ngcontent-%COMP%] .k[_ngcontent-%COMP%]`, but `highlight-ts` injects the `.k`/`.s`/`.c` spans through `[innerHTML]`, so they carry no scope attribute and the rule never matched. Keywords, strings and comments have always rendered in the plain foreground colour. The rules are global now, on five new `--app-code-*` tokens. Widen the colour guard while here: it scanned only `*.component.ts`, so every `*.page.ts`, `*.section.ts` and `*.step.ts` was invisible to it. That is how this page collected 21 hardcoded colours. One other file needed a fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
|
||||
/** Molecule-shaped teaching card: the showcase's "before/after" box. Renders its own
|
||||
tag pill and, when `code` is set, the highlighted snippet — everything else comes in
|
||||
via `<ng-content />`. Angular does not style projected content from the receiving
|
||||
component, so any other card-shaped block inside a section (a sub-label, a nested
|
||||
ok/err result) nests another `<app-concept-card>` rather than writing raw `.tag`
|
||||
markup — that markup would carry the SECTION's scope, not this component's, and the
|
||||
rule here would never match it. */
|
||||
@Component({
|
||||
selector: 'app-concept-card',
|
||||
imports: [],
|
||||
styles: [
|
||||
`
|
||||
.card {
|
||||
border: 1px solid var(--rhc-color-grijs-200);
|
||||
border-radius: 10px;
|
||||
padding: 1.25rem;
|
||||
background: var(--rhc-color-wit);
|
||||
}
|
||||
.card--bad {
|
||||
border-color: var(--rhc-color-rood-300);
|
||||
}
|
||||
.card--good {
|
||||
border-color: var(--rhc-color-groen-300);
|
||||
}
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.tag::before {
|
||||
content: '';
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.tag.bad {
|
||||
color: var(--rhc-color-rood-600);
|
||||
}
|
||||
.tag.bad::before {
|
||||
background: var(--rhc-color-rood-500);
|
||||
}
|
||||
.tag.good {
|
||||
color: var(--rhc-color-groen-700);
|
||||
}
|
||||
.tag.good::before {
|
||||
background: var(--rhc-color-groen-500);
|
||||
}
|
||||
.tag.plain {
|
||||
color: var(--rhc-color-grijs-700);
|
||||
}
|
||||
.tag.plain::before {
|
||||
display: none;
|
||||
}
|
||||
.linked {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
.linked .src {
|
||||
font-size: 0.72rem;
|
||||
color: var(--rhc-color-grijs-700);
|
||||
margin: 0.35rem 0 0;
|
||||
font-family: monospace;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<div
|
||||
class="card"
|
||||
[class.card--good]="variant() === 'good'"
|
||||
[class.card--bad]="variant() === 'bad'"
|
||||
>
|
||||
<p
|
||||
class="tag"
|
||||
[class.good]="variant() === 'good'"
|
||||
[class.bad]="variant() === 'bad'"
|
||||
[class.plain]="variant() === 'plain'"
|
||||
>
|
||||
{{ tag() }}
|
||||
</p>
|
||||
@if (code(); as c) {
|
||||
@if (src(); as s) {
|
||||
<figure class="linked">
|
||||
<pre class="app-code" [innerHTML]="c"></pre>
|
||||
<figcaption class="src">↳ {{ s }}</figcaption>
|
||||
</figure>
|
||||
} @else {
|
||||
<pre class="app-code" [innerHTML]="c"></pre>
|
||||
}
|
||||
}
|
||||
<ng-content />
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ConceptCardComponent {
|
||||
variant = input<'good' | 'bad' | 'plain'>('plain');
|
||||
tag = input.required<string>();
|
||||
code = input<string | undefined>();
|
||||
src = input<string | undefined>();
|
||||
}
|
||||
@@ -1,497 +1,38 @@
|
||||
/* eslint-disable max-lines */ // teaching page covering every concept — removed by RD-24
|
||||
import { Component, computed, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import type { Resource } from '@angular/core';
|
||||
import { Component } from '@angular/core';
|
||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||
import { ASYNC } from '@shared/ui/async/async.component';
|
||||
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
||||
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
|
||||
import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';
|
||||
import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component';
|
||||
import { Registration } from '@registratie/domain/registration';
|
||||
import { parsePostcode } from '@registratie/domain/value-objects/postcode';
|
||||
import { parseBsn } from '@shared/kernel/bsn';
|
||||
import { maskBsn } from '@shared/kernel/pii';
|
||||
import { MaskedValueComponent } from '@shared/ui/masked-value/masked-value.component';
|
||||
import { SNIPPETS } from './snippets.generated';
|
||||
import { highlightTs } from './highlight-ts';
|
||||
import { UnionsSection } from './unions.section';
|
||||
import { RemoteDataSection } from './remote-data.section';
|
||||
import { ParseSection } from './parse.section';
|
||||
import { FormMachineSection } from './form-machine.section';
|
||||
import { VragenlijstSection } from './vragenlijst.section';
|
||||
import { PiiSection } from './pii.section';
|
||||
|
||||
/** Minimal fake Resource so <app-async> can be driven through every state without HTTP. */
|
||||
function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T> {
|
||||
return {
|
||||
value: () => value as T,
|
||||
status: () => status,
|
||||
error: () => error,
|
||||
hasValue: () => value !== undefined,
|
||||
reload: () => {},
|
||||
} as unknown as Resource<T>;
|
||||
}
|
||||
|
||||
/** Teaching showcase: each section pairs the impossible-state-permitting"before"
|
||||
with the"after" where the type system rules it out. Composition-only. */
|
||||
/** Teaching showcase: each section pairs the impossible-state-permitting "before"
|
||||
with the "after" where the type system rules it out. Composition-only. */
|
||||
@Component({
|
||||
selector: 'app-concepts-page',
|
||||
imports: [
|
||||
FormsModule,
|
||||
PageShellComponent,
|
||||
HeadingComponent,
|
||||
TextInputComponent,
|
||||
...ASYNC,
|
||||
SkeletonComponent,
|
||||
RegistrationSummaryComponent,
|
||||
HerregistratieWizardComponent,
|
||||
IntakeWizardComponent,
|
||||
MaskedValueComponent,
|
||||
],
|
||||
styles: [
|
||||
`
|
||||
.section {
|
||||
margin: 0 0 3rem;
|
||||
}
|
||||
.lead {
|
||||
color: var(--rhc-color-grijs-700);
|
||||
max-width: 46rem;
|
||||
margin: 0.25rem 0 1.25rem;
|
||||
}
|
||||
.cols {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid var(--rhc-color-grijs-200, #e5e5e5);
|
||||
border-radius: 10px;
|
||||
padding: 1.25rem;
|
||||
background: #fff;
|
||||
}
|
||||
.card--bad {
|
||||
border-color: var(--rhc-color-rood-300, #f0b4b4);
|
||||
}
|
||||
.card--good {
|
||||
border-color: var(--rhc-color-groen-300, #b4e0b4);
|
||||
}
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.tag::before {
|
||||
content: '';
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.tag.bad {
|
||||
color: var(--rhc-color-rood-600, #a30000);
|
||||
}
|
||||
.tag.bad::before {
|
||||
background: var(--rhc-color-rood-500, #d52b1e);
|
||||
}
|
||||
.tag.good {
|
||||
color: var(--rhc-color-groen-700, #277337);
|
||||
}
|
||||
.tag.good::before {
|
||||
background: var(--rhc-color-groen-500, #39870c);
|
||||
}
|
||||
.tag.plain {
|
||||
color: var(--rhc-color-grijs-700);
|
||||
}
|
||||
.tag.plain::before {
|
||||
display: none;
|
||||
}
|
||||
pre {
|
||||
background: #1e2430;
|
||||
color: #e6e9ef;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
overflow: auto;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
}
|
||||
pre .k {
|
||||
color: #c792ea;
|
||||
}
|
||||
pre .s {
|
||||
color: #c3e88d;
|
||||
}
|
||||
pre .c {
|
||||
color: #7e8aa0;
|
||||
font-style: italic;
|
||||
}
|
||||
.note {
|
||||
font-size: 0.9rem;
|
||||
color: var(--rhc-color-grijs-700);
|
||||
margin: 0.75rem 0 0;
|
||||
}
|
||||
/* live state diagram */
|
||||
.machine {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
.node {
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--rhc-color-grijs-300, #ccc);
|
||||
font-size: 0.82rem;
|
||||
color: var(--rhc-color-grijs-700);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.node.on {
|
||||
background: var(--rhc-color-hemelblauw-100, #e5f1fb);
|
||||
border-color: var(--rhc-color-hemelblauw-500, #007bc7);
|
||||
color: var(--rhc-color-hemelblauw-700, #00567d);
|
||||
font-weight: 700;
|
||||
/* teaching motion: the active state pops as the wizard transitions (the .node
|
||||
transition above animates it; reduced-motion is handled globally). */
|
||||
transform: scale(1.06);
|
||||
}
|
||||
.linked {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
.linked .src {
|
||||
font-size: 0.72rem;
|
||||
color: var(--rhc-color-grijs-700);
|
||||
margin: 0.35rem 0 0;
|
||||
font-family: monospace;
|
||||
}
|
||||
.steplist {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
.pill {
|
||||
padding: 0.3rem 0.7rem;
|
||||
border-radius: 8px;
|
||||
background: var(--rhc-color-grijs-100, #f3f3f3);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.pill.extra {
|
||||
background: var(--rhc-color-geel-100, #fff6d6);
|
||||
border: 1px dashed var(--rhc-color-geel-600, #c79a00);
|
||||
}
|
||||
.arrow {
|
||||
color: var(--rhc-color-grijs-400, #999);
|
||||
}
|
||||
`,
|
||||
UnionsSection,
|
||||
RemoteDataSection,
|
||||
ParseSection,
|
||||
FormMachineSection,
|
||||
VragenlijstSection,
|
||||
PiiSection,
|
||||
],
|
||||
template: `
|
||||
<app-page-shell heading="Onmogelijke toestanden onmogelijk maken" backLink="/dashboard">
|
||||
<p class="lead">
|
||||
<p class="app-lead">
|
||||
Vijf functionele patronen die atomic design makkelijker maakt om te tonen — telkens "fout"
|
||||
(de oude vorm liet het toe) naast"goed" (het type maakt het onmogelijk).
|
||||
</p>
|
||||
|
||||
<!-- 1. Discriminated unions -->
|
||||
<section class="section">
|
||||
<app-heading [level]="2">1 · Discriminated unions</app-heading>
|
||||
<p class="lead">Laat elke variant precies de gegevens dragen die kloppen — niets meer.</p>
|
||||
<div class="cols">
|
||||
<div class="card card--bad">
|
||||
<p class="tag bad">Fout — vlakke interface</p>
|
||||
<pre [innerHTML]="code['unionBad']"></pre>
|
||||
<p class="note">
|
||||
Een doorgehaalde registratie houdt tóch een herregistratiedatum: onmogelijke toestand.
|
||||
</p>
|
||||
</div>
|
||||
<div class="card card--good">
|
||||
<p class="tag good">Goed — sum type</p>
|
||||
<figure class="linked">
|
||||
<pre [innerHTML]="code['union']"></pre>
|
||||
<figcaption class="src">↳ {{ src['union'] }}</figcaption>
|
||||
</figure>
|
||||
<app-registration-summary [reg]="doorgehaald" />
|
||||
<p class="note">
|
||||
De variant <code>Doorgehaald</code> kent geen herregistratiedatum, dus de rij bestaat
|
||||
simpelweg niet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 2. RemoteData fold -->
|
||||
<section class="section">
|
||||
<app-heading [level]="2">2 · RemoteData fold</app-heading>
|
||||
<p class="lead">
|
||||
Eén waarde met vier elkaar uitsluitende toestanden in plaats van drie losse booleans.
|
||||
</p>
|
||||
<div class="cols">
|
||||
<div class="card card--good">
|
||||
<p class="tag good">Vier toestanden, één molecuul</p>
|
||||
<p class="tag plain">Loading</p>
|
||||
<app-async [resource]="loadingRes"
|
||||
><ng-template appAsyncLoaded let-v>{{ v }}</ng-template
|
||||
><ng-template appAsyncLoading
|
||||
><app-skeleton [count]="2" height="1.2rem" [delay]="0" /></ng-template
|
||||
></app-async>
|
||||
<p class="tag plain">Empty</p>
|
||||
<app-async [resource]="emptyRes" [isEmpty]="isEmpty"
|
||||
><ng-template appAsyncLoaded let-v>{{ v }}</ng-template></app-async
|
||||
>
|
||||
<p class="tag plain">Failure</p>
|
||||
<app-async [resource]="errorRes"
|
||||
><ng-template appAsyncLoaded let-v>{{ v }}</ng-template></app-async
|
||||
>
|
||||
<p class="tag plain">Success</p>
|
||||
<app-async [resource]="successRes" [isEmpty]="isEmpty"
|
||||
><ng-template appAsyncLoaded
|
||||
><ul>
|
||||
@for (i of successRes.value(); track i) {
|
||||
<li>{{ i }}</li>
|
||||
}
|
||||
</ul></ng-template
|
||||
></app-async
|
||||
>
|
||||
</div>
|
||||
<div class="card card--good">
|
||||
<p class="tag good">De exhaustieve fold</p>
|
||||
<figure class="linked">
|
||||
<pre [innerHTML]="code['fold']"></pre>
|
||||
<figcaption class="src">↳ {{ src['fold'] }}</figcaption>
|
||||
</figure>
|
||||
<p class="note">
|
||||
Een nieuwe variant toevoegen breekt de compile via <code>assertNever</code> tot je hem
|
||||
afhandelt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 3. Parse, don't validate -->
|
||||
<section class="section">
|
||||
<app-heading [level]="2">3 · Parse, don't validate</app-heading>
|
||||
<p class="lead">Na het parsen onthoudt het <em>type</em> dat de waarde geldig is.</p>
|
||||
<div class="cols">
|
||||
<div class="card">
|
||||
<p class="tag good">Smart constructor → Result</p>
|
||||
<figure class="linked">
|
||||
<pre [innerHTML]="code['parse']"></pre>
|
||||
<figcaption class="src">↳ {{ src['parse'] }}</figcaption>
|
||||
</figure>
|
||||
<app-text-input
|
||||
inputId="pc"
|
||||
[ngModel]="raw()"
|
||||
(ngModelChange)="raw.set($event)"
|
||||
name="pc"
|
||||
placeholder="Typ een postcode, bijv. 1234 AB"
|
||||
/>
|
||||
</div>
|
||||
@let r = parsed();
|
||||
<div class="card" [class.card--good]="r.ok" [class.card--bad]="!r.ok">
|
||||
@if (r.ok) {
|
||||
<div animate.enter="app-item-enter">
|
||||
<p class="tag good">ok</p>
|
||||
<pre>Postcode ="{{ r.value }}"</pre>
|
||||
<p class="note">
|
||||
Een gevalideerde <code>Postcode</code> is een ander type dan een ruwe string.
|
||||
</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div animate.enter="app-item-enter">
|
||||
<p class="tag bad">err</p>
|
||||
<pre>{{ r.error }}</pre>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 4. State machine / wizard (live state diagram) -->
|
||||
<section class="section">
|
||||
<app-heading [level]="2">4 · Form als state machine</app-heading>
|
||||
<p class="lead">
|
||||
Eén tagged union stuurt de UI. Speel met de wizard — de gemarkeerde toestand is de
|
||||
huidige.
|
||||
</p>
|
||||
<div class="cols">
|
||||
<div class="card card--bad">
|
||||
<p class="tag bad">Fout — losse booleans</p>
|
||||
<pre [innerHTML]="code['machineBad']"></pre>
|
||||
<p class="note">
|
||||
Niets verhindert"submitting" mét validatiefouten of een successcherm met errors.
|
||||
</p>
|
||||
</div>
|
||||
<div class="card card--good">
|
||||
<p class="tag good">Goed — één tagged union</p>
|
||||
<figure class="linked">
|
||||
<pre [innerHTML]="code['machine']"></pre>
|
||||
<figcaption class="src">↳ {{ src['machine'] }}</figcaption>
|
||||
</figure>
|
||||
<div class="machine">
|
||||
@for (n of ['Editing', 'Submitting', 'Submitted', 'Failed']; track n) {
|
||||
<span class="node" [class.on]="w.state().tag === n">{{ n }}</span>
|
||||
}
|
||||
</div>
|
||||
<app-herregistratie-wizard #w />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 5. Fixed steps, questions revealed inline -->
|
||||
<section class="section">
|
||||
<app-heading [level]="2"
|
||||
>5 · Vragenlijst met vaste stappen —"vragen tonen, niet stappen toevoegen"</app-heading
|
||||
>
|
||||
<p class="lead">
|
||||
Het aantal stappen ligt vast (<code>STEPS</code>); vervolgvragen verschijnen
|
||||
<em>binnen</em> een stap op basis van eerdere antwoorden. Antwoord"ja" op buitenland of
|
||||
vul weinig uren in, en er komt een extra vraag bij in dezelfde stap — de voortgang"van N"
|
||||
blijft gelijk.
|
||||
</p>
|
||||
<div class="cols">
|
||||
<div class="card card--good">
|
||||
<p class="tag good">Vaste stappen</p>
|
||||
<figure class="linked">
|
||||
<pre [innerHTML]="code['steps']"></pre>
|
||||
<figcaption class="src">↳ {{ src['steps'] }}</figcaption>
|
||||
</figure>
|
||||
<div class="steplist">
|
||||
@for (s of iw.steps; track s; let last = $last) {
|
||||
<span class="pill">{{ s }}</span>
|
||||
@if (!last) {
|
||||
<span class="arrow">→</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<p class="note">
|
||||
De stappen zijn altijd dezelfde; alleen de vragen <em>binnen</em> een stap verschijnen
|
||||
of verdwijnen.
|
||||
</p>
|
||||
</div>
|
||||
<div class="card card--good">
|
||||
<p class="tag good">De wizard</p>
|
||||
<app-intake-wizard #iw />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 6. PII: mask + parse -->
|
||||
<section class="section">
|
||||
<app-heading [level]="2">6 · PII — maskeren & parsen</app-heading>
|
||||
<p class="lead">
|
||||
Een BSN is bijzondere persoonsgegevens (AVG art. 9). Dataminimalisatie: standaard
|
||||
gemaskeerd tonen, alleen tonen na een vastgelegde handeling; en "parse, don't validate" op
|
||||
het gevoeligste veld — een pure functie die de <em>elfproef</em> afdwingt.
|
||||
</p>
|
||||
<div class="cols">
|
||||
<div class="card card--good">
|
||||
<p class="tag good">Maskeren — atom</p>
|
||||
<p>
|
||||
BSN:
|
||||
<app-masked-value
|
||||
[value]="bsnShown()"
|
||||
[canReveal]="true"
|
||||
revealLabel="Toon BSN"
|
||||
(reveal)="bsnRevealed.set(true)"
|
||||
/>
|
||||
</p>
|
||||
<figure class="linked">
|
||||
<pre [innerHTML]="code['mask']"></pre>
|
||||
<figcaption class="src">↳ {{ src['mask'] }}</figcaption>
|
||||
</figure>
|
||||
<p class="note">
|
||||
Standaard gemaskeerd; het echte tonen is step-up-geverifieerd én vastgelegd (zie het
|
||||
behandelscherm). De atom bevat de maskeer-detectie — geen los <code>*</code>-gesnuffel
|
||||
bij elke gebruiker.
|
||||
</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<p class="tag good">Parse (elfproef) → Result</p>
|
||||
<app-text-input
|
||||
inputId="bsn"
|
||||
[ngModel]="bsnRaw()"
|
||||
(ngModelChange)="bsnRaw.set($event)"
|
||||
name="bsn"
|
||||
placeholder="Typ een BSN, bijv. 123456782"
|
||||
/>
|
||||
@let b = bsnParsed();
|
||||
@if (bsnRaw()) {
|
||||
<div animate.enter="app-item-enter">
|
||||
@if (b.ok) {
|
||||
<p class="tag good">ok</p>
|
||||
<pre>Bsn ="{{ b.value }}"</pre>
|
||||
} @else {
|
||||
<p class="tag bad">err</p>
|
||||
<pre>{{ b.error }}</pre>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<figure class="linked">
|
||||
<pre [innerHTML]="code['parseBsn']"></pre>
|
||||
<figcaption class="src">↳ {{ src['parseBsn'] }}</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<app-concepts-unions-section />
|
||||
<app-concepts-remote-data-section />
|
||||
<app-concepts-parse-section />
|
||||
<app-concepts-form-machine-section />
|
||||
<app-concepts-vragenlijst-section />
|
||||
<app-concepts-pii-section />
|
||||
</app-page-shell>
|
||||
`,
|
||||
})
|
||||
export class ConceptsPage {
|
||||
isEmpty = (v: string[]) => !v || v.length === 0;
|
||||
|
||||
doorgehaald: Registration = {
|
||||
bigNummer: '19012345601',
|
||||
naam: 'Dr. A. (Anna) de Vries',
|
||||
beroep: 'Arts',
|
||||
registratiedatum: '2012-09-01',
|
||||
geboortedatum: '1985-03-14',
|
||||
status: { tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'Op eigen verzoek' },
|
||||
};
|
||||
|
||||
loadingRes = fakeResource<string[]>('loading');
|
||||
emptyRes = fakeResource<string[]>('resolved', []);
|
||||
errorRes = fakeResource<string[]>('error', undefined, new Error('Demo'));
|
||||
successRes = fakeResource<string[]>('resolved', ['Huisartsgeneeskunde', 'Spoedeisende hulp']);
|
||||
|
||||
raw = signal('');
|
||||
parsed = computed(() => parsePostcode(this.raw()));
|
||||
|
||||
// 6 · PII demo. Masked-by-default value that reveals locally (the real reveal is
|
||||
// step-up-gated + audited elsewhere); plus a live elfproef parse mirroring the postcode demo.
|
||||
demoBsn = '123456782';
|
||||
bsnRevealed = signal(false);
|
||||
bsnShown = computed(() => (this.bsnRevealed() ? this.demoBsn : maskBsn(this.demoBsn)));
|
||||
bsnRaw = signal('');
|
||||
bsnParsed = computed(() => parseBsn(this.bsnRaw()));
|
||||
|
||||
// Deliberately-wrong illustrations (no real source to link — they show the anti-pattern).
|
||||
private readonly illustrations: Record<string, string> = {
|
||||
unionBad: `interface Registration {
|
||||
status: 'Geregistreerd' | 'Doorgehaald';
|
||||
herregistratieDatum: string; // altijd aanwezig 😬
|
||||
}`,
|
||||
machineBad: `submitting = signal(false);
|
||||
submitted = signal(false);
|
||||
errors = signal<...>({});
|
||||
// submitting === true && errors.size > 0 ? 🤷`,
|
||||
};
|
||||
|
||||
/** Highlighted HTML per snippet: the real ones come from SNIPPETS (extracted from source
|
||||
by gen:snippets — they can't drift), the illustrations are authored above. */
|
||||
protected readonly code: Record<string, string> = Object.fromEntries(
|
||||
Object.entries({ ...SNIPPETS, ...this.illustrations }).map(([k, v]) => [k, highlightTs(v)]),
|
||||
);
|
||||
|
||||
/** The real file each linked snippet is extracted from (shown as a caption). */
|
||||
protected readonly src: Record<string, string> = {
|
||||
union: 'registratie/domain/registration.ts',
|
||||
fold: 'shared/application/remote-data.ts',
|
||||
parse: 'registratie/domain/value-objects/postcode.ts',
|
||||
machine: 'registratie/domain/change-request.machine.ts',
|
||||
steps: 'herregistratie/domain/intake.machine.ts',
|
||||
parseBsn: 'shared/kernel/bsn.ts',
|
||||
mask: 'shared/kernel/pii.ts',
|
||||
};
|
||||
}
|
||||
export class ConceptsPage {}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';
|
||||
import { ConceptCardComponent } from './concept-card.component';
|
||||
import { SNIPPETS } from './snippets.generated';
|
||||
import { highlightTs } from './highlight-ts';
|
||||
|
||||
/** Section 4: form as a state machine, shown as a live state diagram. One tagged union
|
||||
drives the UI — the marked state below is the wizard's current one. Composition-only. */
|
||||
@Component({
|
||||
selector: 'app-concepts-form-machine-section',
|
||||
imports: [HeadingComponent, HerregistratieWizardComponent, ConceptCardComponent],
|
||||
styles: [
|
||||
`
|
||||
.machine {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
.node {
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--rhc-color-grijs-300);
|
||||
font-size: 0.82rem;
|
||||
color: var(--rhc-color-grijs-700);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.node.on {
|
||||
background: var(--rhc-color-hemelblauw-100);
|
||||
border-color: var(--rhc-color-hemelblauw-500);
|
||||
color: var(--rhc-color-hemelblauw-700);
|
||||
font-weight: 700;
|
||||
/* teaching motion: the active state pops as the wizard transitions (the .node
|
||||
transition above animates it; reduced-motion is handled globally). */
|
||||
transform: scale(1.06);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<section class="app-section">
|
||||
<app-heading [level]="2">4 · Form als state machine</app-heading>
|
||||
<p class="app-lead">
|
||||
Eén tagged union stuurt de UI. Speel met de wizard — de gemarkeerde toestand is de huidige.
|
||||
</p>
|
||||
<div class="app-cols">
|
||||
<app-concept-card variant="bad" tag="Fout — losse booleans" [code]="code['machineBad']">
|
||||
<p class="app-note">
|
||||
Niets verhindert"submitting" mét validatiefouten of een successcherm met errors.
|
||||
</p>
|
||||
</app-concept-card>
|
||||
<app-concept-card
|
||||
variant="good"
|
||||
tag="Goed — één tagged union"
|
||||
[code]="code['machine']"
|
||||
[src]="src['machine']"
|
||||
>
|
||||
<div class="machine">
|
||||
@for (n of ['Editing', 'Submitting', 'Submitted', 'Failed']; track n) {
|
||||
<span class="node" [class.on]="w.state().tag === n">{{ n }}</span>
|
||||
}
|
||||
</div>
|
||||
<app-herregistratie-wizard #w />
|
||||
</app-concept-card>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class FormMachineSection {
|
||||
// Deliberately-wrong illustration (no real source to link — it shows the anti-pattern).
|
||||
private readonly machineBad = `submitting = signal(false);
|
||||
submitted = signal(false);
|
||||
errors = signal<...>({});
|
||||
// submitting === true && errors.size > 0 ? 🤷`;
|
||||
|
||||
/** Highlighted HTML per snippet: `machine` comes from SNIPPETS (extracted from source by
|
||||
gen:snippets — it can't drift), `machineBad` is authored above. */
|
||||
protected readonly code: Record<string, string> = {
|
||||
machineBad: highlightTs(this.machineBad),
|
||||
machine: highlightTs(SNIPPETS['machine']),
|
||||
};
|
||||
|
||||
/** The real file the linked snippet is extracted from (shown as a caption). */
|
||||
protected readonly src: Record<string, string> = {
|
||||
machine: 'registratie/domain/change-request.machine.ts',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Component, computed, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||
import { parsePostcode } from '@registratie/domain/value-objects/postcode';
|
||||
import { ConceptCardComponent } from './concept-card.component';
|
||||
import { SNIPPETS } from './snippets.generated';
|
||||
import { highlightTs } from './highlight-ts';
|
||||
|
||||
/** Section 3: parse, don't validate. After parsing, the TYPE remembers the value is valid.
|
||||
Composition-only; owns its own postcode demo. */
|
||||
@Component({
|
||||
selector: 'app-concepts-parse-section',
|
||||
imports: [FormsModule, HeadingComponent, TextInputComponent, ConceptCardComponent],
|
||||
template: `
|
||||
<section class="app-section">
|
||||
<app-heading [level]="2">3 · Parse, don't validate</app-heading>
|
||||
<p class="app-lead">Na het parsen onthoudt het <em>type</em> dat de waarde geldig is.</p>
|
||||
<div class="app-cols">
|
||||
<app-concept-card
|
||||
tag="Smart constructor → Result"
|
||||
[code]="code['parse']"
|
||||
[src]="src['parse']"
|
||||
>
|
||||
<app-text-input
|
||||
inputId="pc"
|
||||
[ngModel]="raw()"
|
||||
(ngModelChange)="raw.set($event)"
|
||||
name="pc"
|
||||
placeholder="Typ een postcode, bijv. 1234 AB"
|
||||
/>
|
||||
</app-concept-card>
|
||||
@let r = parsed();
|
||||
<app-concept-card [variant]="r.ok ? 'good' : 'bad'" [tag]="r.ok ? 'ok' : 'err'">
|
||||
@if (r.ok) {
|
||||
<div animate.enter="app-item-enter">
|
||||
<pre class="app-code">Postcode ="{{ r.value }}"</pre>
|
||||
<p class="app-note">
|
||||
Een gevalideerde <code>Postcode</code> is een ander type dan een ruwe string.
|
||||
</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div animate.enter="app-item-enter">
|
||||
<pre class="app-code">{{ r.error }}</pre>
|
||||
</div>
|
||||
}
|
||||
</app-concept-card>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class ParseSection {
|
||||
raw = signal('');
|
||||
parsed = computed(() => parsePostcode(this.raw()));
|
||||
|
||||
protected readonly code: Record<string, string> = { parse: highlightTs(SNIPPETS['parse']) };
|
||||
protected readonly src: Record<string, string> = {
|
||||
parse: 'registratie/domain/value-objects/postcode.ts',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Component, computed, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||
import { MaskedValueComponent } from '@shared/ui/masked-value/masked-value.component';
|
||||
import { parseBsn } from '@shared/kernel/bsn';
|
||||
import { maskBsn } from '@shared/kernel/pii';
|
||||
import { ConceptCardComponent } from './concept-card.component';
|
||||
import { SNIPPETS } from './snippets.generated';
|
||||
import { highlightTs } from './highlight-ts';
|
||||
|
||||
/** Section 6: PII — masking and parsing. A BSN (Dutch citizen service number) is
|
||||
special-category personal data (GDPR art. 9): masked by default, revealed only after
|
||||
a logged action; "parse, don't validate" on the most sensitive field — a pure function
|
||||
enforces the checksum. Composition-only. The ok/err result nests its own
|
||||
`<app-concept-card>` for its label, for the same reason section 2 does. */
|
||||
@Component({
|
||||
selector: 'app-concepts-pii-section',
|
||||
imports: [
|
||||
FormsModule,
|
||||
HeadingComponent,
|
||||
TextInputComponent,
|
||||
MaskedValueComponent,
|
||||
ConceptCardComponent,
|
||||
],
|
||||
template: `
|
||||
<section class="app-section">
|
||||
<app-heading [level]="2">6 · PII — maskeren & parsen</app-heading>
|
||||
<p class="app-lead">
|
||||
Een BSN is bijzondere persoonsgegevens (AVG art. 9). Dataminimalisatie: standaard gemaskeerd
|
||||
tonen, alleen tonen na een vastgelegde handeling; en"parse, don't validate" op het
|
||||
gevoeligste veld — een pure functie die de <em>elfproef</em> afdwingt.
|
||||
</p>
|
||||
<div class="app-cols">
|
||||
<app-concept-card
|
||||
variant="good"
|
||||
tag="Maskeren — atom"
|
||||
[code]="code['mask']"
|
||||
[src]="src['mask']"
|
||||
>
|
||||
<p>
|
||||
BSN:
|
||||
<app-masked-value
|
||||
[value]="bsnShown()"
|
||||
[canReveal]="true"
|
||||
revealLabel="Toon BSN"
|
||||
(reveal)="bsnRevealed.set(true)"
|
||||
/>
|
||||
</p>
|
||||
<p class="app-note">
|
||||
Standaard gemaskeerd; het echte tonen is step-up-geverifieerd én vastgelegd (zie het
|
||||
behandelscherm). De atom bevat de maskeer-detectie — geen los <code>*</code>-gesnuffel
|
||||
bij elke gebruiker.
|
||||
</p>
|
||||
</app-concept-card>
|
||||
<app-concept-card
|
||||
tag="Parse (elfproef) → Result"
|
||||
[code]="code['parseBsn']"
|
||||
[src]="src['parseBsn']"
|
||||
>
|
||||
<app-text-input
|
||||
inputId="bsn"
|
||||
[ngModel]="bsnRaw()"
|
||||
(ngModelChange)="bsnRaw.set($event)"
|
||||
name="bsn"
|
||||
placeholder="Typ een BSN, bijv. 123456782"
|
||||
/>
|
||||
@let b = bsnParsed();
|
||||
@if (bsnRaw()) {
|
||||
<div animate.enter="app-item-enter">
|
||||
<app-concept-card [variant]="b.ok ? 'good' : 'bad'" [tag]="b.ok ? 'ok' : 'err'">
|
||||
@if (b.ok) {
|
||||
<pre class="app-code">Bsn ="{{ b.value }}"</pre>
|
||||
} @else {
|
||||
<pre class="app-code">{{ b.error }}</pre>
|
||||
}
|
||||
</app-concept-card>
|
||||
</div>
|
||||
}
|
||||
</app-concept-card>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class PiiSection {
|
||||
// Masked-by-default value that reveals locally (the real reveal is step-up-gated +
|
||||
// audited elsewhere); plus a live elfproef parse mirroring the postcode demo.
|
||||
demoBsn = '123456782';
|
||||
bsnRevealed = signal(false);
|
||||
bsnShown = computed(() => (this.bsnRevealed() ? this.demoBsn : maskBsn(this.demoBsn)));
|
||||
bsnRaw = signal('');
|
||||
bsnParsed = computed(() => parseBsn(this.bsnRaw()));
|
||||
|
||||
protected readonly code: Record<string, string> = {
|
||||
mask: highlightTs(SNIPPETS['mask']),
|
||||
parseBsn: highlightTs(SNIPPETS['parseBsn']),
|
||||
};
|
||||
protected readonly src: Record<string, string> = {
|
||||
mask: 'shared/kernel/pii.ts',
|
||||
parseBsn: 'shared/kernel/bsn.ts',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Component } from '@angular/core';
|
||||
import type { Resource } from '@angular/core';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { ASYNC } from '@shared/ui/async/async.component';
|
||||
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
||||
import { ConceptCardComponent } from './concept-card.component';
|
||||
import { SNIPPETS } from './snippets.generated';
|
||||
import { highlightTs } from './highlight-ts';
|
||||
|
||||
/** Minimal fake Resource so <app-async> can be driven through every state without HTTP. */
|
||||
function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T> {
|
||||
return {
|
||||
value: () => value as T,
|
||||
status: () => status,
|
||||
error: () => error,
|
||||
hasValue: () => value !== undefined,
|
||||
reload: () => {},
|
||||
} as unknown as Resource<T>;
|
||||
}
|
||||
|
||||
/** Section 2: RemoteData fold. One value with four mutually exclusive states, instead of
|
||||
three loose booleans. Composition-only; owns its own fake resources. Each of the four
|
||||
demo states nests its own `<app-concept-card>` for its label — a plain `.tag` element
|
||||
written here would carry this section's scope, not the card's, and stay unstyled. */
|
||||
@Component({
|
||||
selector: 'app-concepts-remote-data-section',
|
||||
imports: [HeadingComponent, ...ASYNC, SkeletonComponent, ConceptCardComponent],
|
||||
template: `
|
||||
<section class="app-section">
|
||||
<app-heading [level]="2">2 · RemoteData fold</app-heading>
|
||||
<p class="app-lead">
|
||||
Eén waarde met vier elkaar uitsluitende toestanden in plaats van drie losse booleans.
|
||||
</p>
|
||||
<div class="app-cols">
|
||||
<app-concept-card variant="good" tag="Vier toestanden, één molecuul">
|
||||
<div class="app-stack">
|
||||
<app-concept-card variant="plain" tag="Loading">
|
||||
<app-async [resource]="loadingRes"
|
||||
><ng-template appAsyncLoaded let-v>{{ v }}</ng-template
|
||||
><ng-template appAsyncLoading
|
||||
><app-skeleton [count]="2" height="1.2rem" [delay]="0" /></ng-template
|
||||
></app-async>
|
||||
</app-concept-card>
|
||||
<app-concept-card variant="plain" tag="Empty">
|
||||
<app-async [resource]="emptyRes" [isEmpty]="isEmpty"
|
||||
><ng-template appAsyncLoaded let-v>{{ v }}</ng-template></app-async
|
||||
>
|
||||
</app-concept-card>
|
||||
<app-concept-card variant="plain" tag="Failure">
|
||||
<app-async [resource]="errorRes"
|
||||
><ng-template appAsyncLoaded let-v>{{ v }}</ng-template></app-async
|
||||
>
|
||||
</app-concept-card>
|
||||
<app-concept-card variant="plain" tag="Success">
|
||||
<app-async [resource]="successRes" [isEmpty]="isEmpty"
|
||||
><ng-template appAsyncLoaded
|
||||
><ul>
|
||||
@for (i of successRes.value(); track i) {
|
||||
<li>{{ i }}</li>
|
||||
}
|
||||
</ul></ng-template
|
||||
></app-async
|
||||
>
|
||||
</app-concept-card>
|
||||
</div>
|
||||
</app-concept-card>
|
||||
<app-concept-card
|
||||
variant="good"
|
||||
tag="De exhaustieve fold"
|
||||
[code]="code['fold']"
|
||||
[src]="src['fold']"
|
||||
>
|
||||
<p class="app-note">
|
||||
Een nieuwe variant toevoegen breekt de compile via <code>assertNever</code> tot je hem
|
||||
afhandelt.
|
||||
</p>
|
||||
</app-concept-card>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class RemoteDataSection {
|
||||
isEmpty = (v: string[]) => !v || v.length === 0;
|
||||
|
||||
loadingRes = fakeResource<string[]>('loading');
|
||||
emptyRes = fakeResource<string[]>('resolved', []);
|
||||
errorRes = fakeResource<string[]>('error', undefined, new Error('Demo'));
|
||||
successRes = fakeResource<string[]>('resolved', ['Huisartsgeneeskunde', 'Spoedeisende hulp']);
|
||||
|
||||
protected readonly code: Record<string, string> = { fold: highlightTs(SNIPPETS['fold']) };
|
||||
protected readonly src: Record<string, string> = { fold: 'shared/application/remote-data.ts' };
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
|
||||
import { Registration } from '@registratie/domain/registration';
|
||||
import { ConceptCardComponent } from './concept-card.component';
|
||||
import { SNIPPETS } from './snippets.generated';
|
||||
import { highlightTs } from './highlight-ts';
|
||||
|
||||
/** Section 1: discriminated unions. Each variant carries exactly the data that fits it —
|
||||
nothing more. Composition-only; owns its own demo data. */
|
||||
@Component({
|
||||
selector: 'app-concepts-unions-section',
|
||||
imports: [HeadingComponent, RegistrationSummaryComponent, ConceptCardComponent],
|
||||
template: `
|
||||
<section class="app-section">
|
||||
<app-heading [level]="2">1 · Discriminated unions</app-heading>
|
||||
<p class="app-lead">Laat elke variant precies de gegevens dragen die kloppen — niets meer.</p>
|
||||
<div class="app-cols">
|
||||
<app-concept-card variant="bad" tag="Fout — vlakke interface" [code]="code['unionBad']">
|
||||
<p class="app-note">
|
||||
Een doorgehaalde registratie houdt tóch een herregistratiedatum: onmogelijke toestand.
|
||||
</p>
|
||||
</app-concept-card>
|
||||
<app-concept-card
|
||||
variant="good"
|
||||
tag="Goed — sum type"
|
||||
[code]="code['union']"
|
||||
[src]="src['union']"
|
||||
>
|
||||
<app-registration-summary [reg]="doorgehaald" />
|
||||
<p class="app-note">
|
||||
De variant <code>Doorgehaald</code> kent geen herregistratiedatum, dus de rij bestaat
|
||||
simpelweg niet.
|
||||
</p>
|
||||
</app-concept-card>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class UnionsSection {
|
||||
doorgehaald: Registration = {
|
||||
bigNummer: '19012345601',
|
||||
naam: 'Dr. A. (Anna) de Vries',
|
||||
beroep: 'Arts',
|
||||
registratiedatum: '2012-09-01',
|
||||
geboortedatum: '1985-03-14',
|
||||
status: { tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'Op eigen verzoek' },
|
||||
};
|
||||
|
||||
// Deliberately-wrong illustration (no real source to link — it shows the anti-pattern).
|
||||
private readonly unionBad = `interface Registration {
|
||||
status: 'Geregistreerd' | 'Doorgehaald';
|
||||
herregistratieDatum: string; // altijd aanwezig 😬
|
||||
}`;
|
||||
|
||||
/** Highlighted HTML per snippet: `union` comes from SNIPPETS (extracted from source by
|
||||
gen:snippets — it can't drift), `unionBad` is authored above. */
|
||||
protected readonly code: Record<string, string> = {
|
||||
unionBad: highlightTs(this.unionBad),
|
||||
union: highlightTs(SNIPPETS['union']),
|
||||
};
|
||||
|
||||
/** The real file the linked snippet is extracted from (shown as a caption). */
|
||||
protected readonly src: Record<string, string> = {
|
||||
union: 'registratie/domain/registration.ts',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component';
|
||||
import { ConceptCardComponent } from './concept-card.component';
|
||||
import { SNIPPETS } from './snippets.generated';
|
||||
import { highlightTs } from './highlight-ts';
|
||||
|
||||
/** Section 5: a questionnaire with a fixed step count — "show questions, don't add
|
||||
steps". The step count never changes; follow-up questions reveal inline based on
|
||||
earlier answers. Composition-only. */
|
||||
@Component({
|
||||
selector: 'app-concepts-vragenlijst-section',
|
||||
imports: [HeadingComponent, IntakeWizardComponent, ConceptCardComponent],
|
||||
styles: [
|
||||
`
|
||||
.steplist {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
.pill {
|
||||
padding: 0.3rem 0.7rem;
|
||||
border-radius: 8px;
|
||||
background: var(--rhc-color-grijs-100);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.pill.extra {
|
||||
background: var(--rhc-color-geel-100);
|
||||
border: 1px dashed var(--rhc-color-geel-600);
|
||||
}
|
||||
.arrow {
|
||||
color: var(--rhc-color-grijs-400);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<section class="app-section">
|
||||
<app-heading [level]="2"
|
||||
>5 · Vragenlijst met vaste stappen —"vragen tonen, niet stappen toevoegen"</app-heading
|
||||
>
|
||||
<p class="app-lead">
|
||||
Het aantal stappen ligt vast (<code>STEPS</code>); vervolgvragen verschijnen
|
||||
<em>binnen</em> een stap op basis van eerdere antwoorden. Antwoord"ja" op buitenland of vul
|
||||
weinig uren in, en er komt een extra vraag bij in dezelfde stap — de voortgang"van N" blijft
|
||||
gelijk.
|
||||
</p>
|
||||
<div class="app-cols">
|
||||
<app-concept-card
|
||||
variant="good"
|
||||
tag="Vaste stappen"
|
||||
[code]="code['steps']"
|
||||
[src]="src['steps']"
|
||||
>
|
||||
<div class="steplist">
|
||||
@for (s of iw.steps; track s; let last = $last) {
|
||||
<span class="pill">{{ s }}</span>
|
||||
@if (!last) {
|
||||
<span class="arrow">→</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<p class="app-note">
|
||||
De stappen zijn altijd dezelfde; alleen de vragen <em>binnen</em> een stap verschijnen
|
||||
of verdwijnen.
|
||||
</p>
|
||||
</app-concept-card>
|
||||
<app-concept-card variant="good" tag="De wizard">
|
||||
<app-intake-wizard #iw />
|
||||
</app-concept-card>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class VragenlijstSection {
|
||||
protected readonly code: Record<string, string> = { steps: highlightTs(SNIPPETS['steps']) };
|
||||
protected readonly src: Record<string, string> = {
|
||||
steps: 'herregistratie/domain/intake.machine.ts',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user