Restructure into DDD bounded contexts + functional state management
Reorganise from atomic-design-only folders into bounded contexts (auth / registratie / herregistratie) over a shared kernel, each split into domain / application / infrastructure / ui layers. Dependencies point inward; the domain layer is framework-free. Path aliases (@shared/@auth/@registratie/ @herregistratie) make import direction explicit. State management (Elm-style, native TS, no new deps): - shared/application/store.ts — createStore(init, update): pure reducer + signal - shared/application/remote-data.ts — add map/map2/map3/andThen combinators so several services fold into one RemoteData; <app-async> gains an [rd] input - registratie/application/big-profile.store.ts — root singleton combining the BIG-register and BRP services via map2 into one state; holds the optimistic herregistratie flag shared with the dashboard - herregistratie: machine gains a WizardMsg union + pure reduce; submit is a command that calls infra and dispatches the result, with optimistic update + rollback against the shared store - auth: SessionStore + DigiD adapter + functional route guard; login establishes the session, protected routes use canActivate Rich domain: registration.policy.ts (statusColor/label, herregistratie eligibility, invariants); BigNummer/Postcode/Uren value objects with smart constructors. status-badge is now domain-free (colour/label inputs). Specs for the reducer, RemoteData combinators, and eligibility policy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
22
src/app/shared/ui/alert/alert.component.ts
Normal file
22
src/app/shared/ui/alert/alert.component.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
|
||||
type AlertType = 'info' | 'ok' | 'warning' | 'error';
|
||||
|
||||
/** Atom: alert/message banner. */
|
||||
@Component({
|
||||
selector: 'app-alert',
|
||||
template: `
|
||||
<div
|
||||
class="utrecht-alert rhc-alert"
|
||||
[class.utrecht-alert--info]="type() === 'info'"
|
||||
[class.utrecht-alert--ok]="type() === 'ok'"
|
||||
[class.utrecht-alert--warning]="type() === 'warning'"
|
||||
[class.utrecht-alert--error]="type() === 'error'"
|
||||
role="status">
|
||||
<ng-content />
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class AlertComponent {
|
||||
type = input<AlertType>('info');
|
||||
}
|
||||
18
src/app/shared/ui/alert/alert.stories.ts
Normal file
18
src/app/shared/ui/alert/alert.stories.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { AlertComponent } from './alert.component';
|
||||
|
||||
const meta: Meta<AlertComponent> = {
|
||||
title: 'Atoms/Alert',
|
||||
component: AlertComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-alert [type]="type">Uw wijziging is ontvangen.</app-alert>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<AlertComponent>;
|
||||
|
||||
export const Info: Story = { args: { type: 'info' } };
|
||||
export const Ok: Story = { args: { type: 'ok' } };
|
||||
export const Warning: Story = { args: { type: 'warning' } };
|
||||
export const Error: Story = { args: { type: 'error' } };
|
||||
107
src/app/shared/ui/async/async.component.ts
Normal file
107
src/app/shared/ui/async/async.component.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Component, Directive, TemplateRef, computed, contentChild, input } from '@angular/core';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import type { Resource } from '@angular/core';
|
||||
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { RemoteData, fromResource, foldRemote } from '@shared/application/remote-data';
|
||||
|
||||
/* Slot markers. Put on <ng-template> children of <app-async>. */
|
||||
@Directive({ selector: '[appAsyncLoaded]' })
|
||||
export class AsyncLoadedDirective {
|
||||
constructor(public tpl: TemplateRef<{ $implicit: unknown }>) {}
|
||||
}
|
||||
@Directive({ selector: '[appAsyncLoading]' })
|
||||
export class AsyncLoadingDirective {
|
||||
constructor(public tpl: TemplateRef<unknown>) {}
|
||||
}
|
||||
@Directive({ selector: '[appAsyncEmpty]' })
|
||||
export class AsyncEmptyDirective {
|
||||
constructor(public tpl: TemplateRef<unknown>) {}
|
||||
}
|
||||
@Directive({ selector: '[appAsyncError]' })
|
||||
export class AsyncErrorDirective {
|
||||
constructor(public tpl: TemplateRef<{ $implicit: Error | undefined; retry: () => void }>) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders exactly ONE of loading / empty / error / loaded for a signal-based
|
||||
* resource (e.g. httpResource). Built on a RemoteData tagged union (see
|
||||
* core/remote-data.ts), so the states are mutually exclusive by construction —
|
||||
* 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 (rd().tag) {
|
||||
@case ('Loading') {
|
||||
@if (loadingTpl()) { <ng-container [ngTemplateOutlet]="loadingTpl()!.tpl" /> }
|
||||
@else { <app-spinner /> }
|
||||
}
|
||||
@case ('Failure') {
|
||||
@if (errorTpl()) {
|
||||
<ng-container [ngTemplateOutlet]="errorTpl()!.tpl"
|
||||
[ngTemplateOutletContext]="{ $implicit: error(), retry: retry }" />
|
||||
} @else {
|
||||
<app-alert type="error">Er ging iets mis bij het laden van de gegevens.</app-alert>
|
||||
<div style="margin-top:1rem">
|
||||
<app-button variant="secondary" (click)="retry()">Opnieuw proberen</app-button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@case ('Empty') {
|
||||
@if (emptyTpl()) { <ng-container [ngTemplateOutlet]="emptyTpl()!.tpl" /> }
|
||||
@else { <p class="rhc-paragraph">Geen gegevens gevonden.</p> }
|
||||
}
|
||||
@case ('Success') {
|
||||
<ng-container [ngTemplateOutlet]="loadedTpl().tpl"
|
||||
[ngTemplateOutletContext]="{ $implicit: value() }" />
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class AsyncComponent<T> {
|
||||
// Two ways to feed this component:
|
||||
// [resource] — a raw httpResource (the common case), or
|
||||
// [data] — an already-combined RemoteData (e.g. from a store via map2).
|
||||
resource = input<Resource<T>>();
|
||||
data = input<RemoteData<Error | undefined, T>>();
|
||||
isEmpty = input<(v: T) => boolean>(() => false);
|
||||
|
||||
loadedTpl = contentChild.required(AsyncLoadedDirective);
|
||||
loadingTpl = contentChild(AsyncLoadingDirective);
|
||||
emptyTpl = contentChild(AsyncEmptyDirective);
|
||||
errorTpl = contentChild(AsyncErrorDirective);
|
||||
|
||||
// Single source of truth: the supplied RemoteData, or the resource projected into one.
|
||||
protected rd = computed<RemoteData<Error | undefined, T>>(() => {
|
||||
const data = this.data();
|
||||
if (data) return data;
|
||||
const r = this.resource();
|
||||
return r ? fromResource(r, this.isEmpty()) : { tag: 'Loading' };
|
||||
});
|
||||
|
||||
// value/error are pulled out via the exhaustive fold — only Success carries a
|
||||
// value, only Failure carries an error, so these can't lie.
|
||||
protected value = computed(() =>
|
||||
foldRemote(this.rd(), { loading: () => undefined, empty: () => undefined, failure: () => undefined, success: (v) => v }),
|
||||
);
|
||||
protected error = computed(() =>
|
||||
foldRemote(this.rd(), { loading: () => undefined, empty: () => undefined, failure: (e) => e, success: () => undefined }),
|
||||
);
|
||||
|
||||
retry = () => {
|
||||
const r = this.resource();
|
||||
if (r && 'reload' in r && typeof (r as { reload?: unknown }).reload === 'function') {
|
||||
(r as { reload: () => void }).reload();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Convenience: import this array to get the wrapper + all slot directives. */
|
||||
export const ASYNC = [
|
||||
AsyncComponent, AsyncLoadedDirective, AsyncLoadingDirective,
|
||||
AsyncEmptyDirective, AsyncErrorDirective,
|
||||
] as const;
|
||||
43
src/app/shared/ui/async/async.stories.ts
Normal file
43
src/app/shared/ui/async/async.stories.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { moduleMetadata } from '@storybook/angular';
|
||||
import type { Resource } from '@angular/core';
|
||||
import { ASYNC } from './async.component';
|
||||
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
||||
|
||||
/** Minimal fake of a signal Resource so the wrapper can be driven through every
|
||||
state in isolation (no HTTP). */
|
||||
function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T> {
|
||||
return {
|
||||
value: () => value as T,
|
||||
status: () => status,
|
||||
error: () => error,
|
||||
hasValue: () => value !== undefined,
|
||||
reload: () => {},
|
||||
} as unknown as Resource<T>;
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Molecules/Async States',
|
||||
decorators: [moduleMetadata({ imports: [...ASYNC, SkeletonComponent] })],
|
||||
render: (args) => ({
|
||||
// isEmpty is a function — Storybook strips function args, so set it here.
|
||||
props: { ...args, isEmpty: (v: string[]) => !v || v.length === 0 },
|
||||
template: `
|
||||
<app-async [resource]="resource" [isEmpty]="isEmpty">
|
||||
<ng-template appAsyncLoaded let-items>
|
||||
<ul class="rhc-unordered-list">
|
||||
@for (i of items; track i) { <li>{{ i }}</li> }
|
||||
</ul>
|
||||
</ng-template>
|
||||
<ng-template appAsyncLoading><app-skeleton [count]="3" height="1.5rem" [delay]="0" /></ng-template>
|
||||
<ng-template appAsyncEmpty><p class="rhc-paragraph">Geen items gevonden.</p></ng-template>
|
||||
</app-async>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj;
|
||||
|
||||
export const Loaded: Story = { args: { resource: fakeResource('resolved', ['Huisartsgeneeskunde', 'Spoedeisende hulp']) } };
|
||||
export const Loading: Story = { args: { resource: fakeResource('loading') } };
|
||||
export const Empty: Story = { args: { resource: fakeResource('resolved', [] as string[]) } };
|
||||
export const ErrorState: Story = { args: { resource: fakeResource('error', undefined, new Error('Demo')) } };
|
||||
26
src/app/shared/ui/button/button.component.ts
Normal file
26
src/app/shared/ui/button/button.component.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
|
||||
type Variant = 'primary' | 'secondary' | 'subtle' | 'danger';
|
||||
|
||||
/** Atom: button. Thin wrapper over the Utrecht/RHC button CSS. */
|
||||
@Component({
|
||||
selector: 'app-button',
|
||||
template: `
|
||||
<button
|
||||
[type]="type()"
|
||||
[disabled]="disabled()"
|
||||
class="utrecht-button rhc-button"
|
||||
[class.utrecht-button--primary-action]="variant() === 'primary'"
|
||||
[class.utrecht-button--secondary-action]="variant() === 'secondary'"
|
||||
[class.utrecht-button--subtle]="variant() === 'subtle'"
|
||||
[class.utrecht-button--danger]="variant() === 'danger'"
|
||||
[class.utrecht-button--disabled]="disabled()">
|
||||
<ng-content />
|
||||
</button>
|
||||
`,
|
||||
})
|
||||
export class ButtonComponent {
|
||||
variant = input<Variant>('primary');
|
||||
type = input<'button' | 'submit'>('button');
|
||||
disabled = input(false);
|
||||
}
|
||||
19
src/app/shared/ui/button/button.stories.ts
Normal file
19
src/app/shared/ui/button/button.stories.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { ButtonComponent } from './button.component';
|
||||
|
||||
const meta: Meta<ButtonComponent> = {
|
||||
title: 'Atoms/Button',
|
||||
component: ButtonComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-button [variant]="variant" [type]="type" [disabled]="disabled">Knop</app-button>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<ButtonComponent>;
|
||||
|
||||
export const Primary: Story = { args: { variant: 'primary' } };
|
||||
export const Secondary: Story = { args: { variant: 'secondary' } };
|
||||
export const Subtle: Story = { args: { variant: 'subtle' } };
|
||||
export const Danger: Story = { args: { variant: 'danger' } };
|
||||
export const Disabled: Story = { args: { variant: 'primary', disabled: true } };
|
||||
17
src/app/shared/ui/data-row/data-row.component.ts
Normal file
17
src/app/shared/ui/data-row/data-row.component.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
|
||||
/** Molecule: one key/value row inside an RHC data-summary.
|
||||
Wrap several of these in .rhc-data-summary (see registration-summary). */
|
||||
@Component({
|
||||
selector: 'app-data-row',
|
||||
template: `
|
||||
<div class="rhc-data-summary__item">
|
||||
<dt class="rhc-data-summary__item-key">{{ key() }}</dt>
|
||||
<dd class="rhc-data-summary__item-value"><ng-content>{{ value() }}</ng-content></dd>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class DataRowComponent {
|
||||
key = input.required<string>();
|
||||
value = input<string | null>('');
|
||||
}
|
||||
25
src/app/shared/ui/form-field/form-field.component.ts
Normal file
25
src/app/shared/ui/form-field/form-field.component.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
|
||||
/** Molecule: form field = label + projected control + optional error/description.
|
||||
Reused by both the login form and the change-request form. */
|
||||
@Component({
|
||||
selector: 'app-form-field',
|
||||
template: `
|
||||
<div class="rhc-form-field utrecht-form-field" [class.utrecht-form-field--invalid]="!!error()">
|
||||
<label class="utrecht-form-label" [for]="fieldId()">{{ label() }}</label>
|
||||
@if (description()) {
|
||||
<div class="utrecht-form-field-description">{{ description() }}</div>
|
||||
}
|
||||
<ng-content />
|
||||
@if (error()) {
|
||||
<div class="utrecht-form-field-error-message" role="alert">{{ error() }}</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class FormFieldComponent {
|
||||
label = input.required<string>();
|
||||
fieldId = input.required<string>();
|
||||
description = input<string>();
|
||||
error = input<string>();
|
||||
}
|
||||
26
src/app/shared/ui/form-field/form-field.stories.ts
Normal file
26
src/app/shared/ui/form-field/form-field.stories.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { moduleMetadata } from '@storybook/angular';
|
||||
import { FormFieldComponent } from './form-field.component';
|
||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||
|
||||
const meta: Meta<FormFieldComponent> = {
|
||||
title: 'Molecules/Form Field',
|
||||
component: FormFieldComponent,
|
||||
decorators: [moduleMetadata({ imports: [TextInputComponent] })],
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<app-form-field [label]="label" [fieldId]="fieldId" [description]="description" [error]="error">
|
||||
<app-text-input [inputId]="fieldId" [invalid]="!!error" placeholder="Vul in" />
|
||||
</app-form-field>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<FormFieldComponent>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: { label: 'BSN', fieldId: 'bsn', description: '9 cijfers' },
|
||||
};
|
||||
export const WithError: Story = {
|
||||
args: { label: 'Straat en huisnummer', fieldId: 'street', error: 'Dit veld is verplicht.' },
|
||||
};
|
||||
23
src/app/shared/ui/heading/heading.component.ts
Normal file
23
src/app/shared/ui/heading/heading.component.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
|
||||
/** Atom: heading. Renders the right h1..h5 with RHC heading styling.
|
||||
Single <ng-content> captured in a template — multiple ng-content across
|
||||
@switch branches silently drops the projected content. */
|
||||
@Component({
|
||||
selector: 'app-heading',
|
||||
imports: [NgTemplateOutlet],
|
||||
template: `
|
||||
<ng-template #content><ng-content /></ng-template>
|
||||
@switch (level()) {
|
||||
@case (1) { <h1 class="rhc-heading nl-heading--level-1"><ng-container [ngTemplateOutlet]="content" /></h1> }
|
||||
@case (2) { <h2 class="rhc-heading nl-heading--level-2"><ng-container [ngTemplateOutlet]="content" /></h2> }
|
||||
@case (3) { <h3 class="rhc-heading nl-heading--level-3"><ng-container [ngTemplateOutlet]="content" /></h3> }
|
||||
@case (4) { <h4 class="rhc-heading nl-heading--level-4"><ng-container [ngTemplateOutlet]="content" /></h4> }
|
||||
@default { <h5 class="rhc-heading nl-heading--level-5"><ng-container [ngTemplateOutlet]="content" /></h5> }
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class HeadingComponent {
|
||||
level = input<1 | 2 | 3 | 4 | 5>(2);
|
||||
}
|
||||
17
src/app/shared/ui/heading/heading.stories.ts
Normal file
17
src/app/shared/ui/heading/heading.stories.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { HeadingComponent } from './heading.component';
|
||||
|
||||
const meta: Meta<HeadingComponent> = {
|
||||
title: 'Atoms/Heading',
|
||||
component: HeadingComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-heading [level]="level">Mijn BIG-registratie</app-heading>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<HeadingComponent>;
|
||||
|
||||
export const Level1: Story = { args: { level: 1 } };
|
||||
export const Level2: Story = { args: { level: 2 } };
|
||||
export const Level3: Story = { args: { level: 3 } };
|
||||
12
src/app/shared/ui/link/link.component.ts
Normal file
12
src/app/shared/ui/link/link.component.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
/** Atom: link. Internal router link styled as an RHC/Utrecht link. */
|
||||
@Component({
|
||||
selector: 'app-link',
|
||||
imports: [RouterLink],
|
||||
template: `<a [routerLink]="to()" class="rhc-link nl-link utrecht-link"><ng-content /></a>`,
|
||||
})
|
||||
export class LinkComponent {
|
||||
to = input.required<string>();
|
||||
}
|
||||
33
src/app/shared/ui/skeleton/skeleton.component.ts
Normal file
33
src/app/shared/ui/skeleton/skeleton.component.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Component, OnDestroy, OnInit, computed, input, signal } from '@angular/core';
|
||||
|
||||
/** Atom: skeleton placeholder (grey shimmer). Delay-gated so it never flashes
|
||||
on fast responses. Render `count` lines shaped roughly like the content. */
|
||||
@Component({
|
||||
selector: 'app-skeleton',
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.sk{background:linear-gradient(90deg,#e8ebee 25%,#f3f5f6 37%,#e8ebee 63%);
|
||||
background-size:400% 100%;animation:sh 1.4s ease infinite;border-radius:4px;
|
||||
margin-block-end:0.6rem}
|
||||
@keyframes sh{0%{background-position:100% 0}100%{background-position:0 0}}
|
||||
`],
|
||||
template: `
|
||||
@if (visible()) {
|
||||
@for (l of lines(); track $index) {
|
||||
<div class="sk" [style.width]="width()" [style.height]="height()"></div>
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class SkeletonComponent implements OnInit, OnDestroy {
|
||||
width = input('100%');
|
||||
height = input('1rem');
|
||||
count = input(1);
|
||||
delay = input(150);
|
||||
protected visible = signal(false);
|
||||
protected lines = computed(() => Array(this.count()).fill(0));
|
||||
private timer?: ReturnType<typeof setTimeout>;
|
||||
|
||||
ngOnInit() { this.timer = setTimeout(() => this.visible.set(true), this.delay()); }
|
||||
ngOnDestroy() { clearTimeout(this.timer); }
|
||||
}
|
||||
13
src/app/shared/ui/skeleton/skeleton.stories.ts
Normal file
13
src/app/shared/ui/skeleton/skeleton.stories.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { SkeletonComponent } from './skeleton.component';
|
||||
|
||||
const meta: Meta<SkeletonComponent> = {
|
||||
title: 'Atoms/Skeleton',
|
||||
component: SkeletonComponent,
|
||||
args: { delay: 0 },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<SkeletonComponent>;
|
||||
|
||||
export const SingleLine: Story = { args: { width: '60%', height: '1rem' } };
|
||||
export const CardPlaceholder: Story = { args: { height: '2.5rem', count: 6 } };
|
||||
28
src/app/shared/ui/spinner/spinner.component.ts
Normal file
28
src/app/shared/ui/spinner/spinner.component.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Component, OnDestroy, OnInit, input, signal } from '@angular/core';
|
||||
|
||||
/** Atom: spinner that only appears after `delay` ms — fast responses never
|
||||
flash a spinner, slow ones get feedback. */
|
||||
@Component({
|
||||
selector: 'app-spinner',
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.sp{width:2rem;height:2rem;border-radius:50%;
|
||||
border:3px solid var(--rhc-color-grijs-300,#cad0d6);
|
||||
border-block-start-color:var(--rhc-color-lintblauw-700,#154273);
|
||||
animation:sp 0.8s linear infinite;margin:1.5rem auto}
|
||||
@keyframes sp{to{transform:rotate(360deg)}}
|
||||
`],
|
||||
template: `
|
||||
@if (visible()) {
|
||||
<div class="sp" role="status" aria-label="Bezig met laden"></div>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class SpinnerComponent implements OnInit, OnDestroy {
|
||||
delay = input(250);
|
||||
protected visible = signal(false);
|
||||
private timer?: ReturnType<typeof setTimeout>;
|
||||
|
||||
ngOnInit() { this.timer = setTimeout(() => this.visible.set(true), this.delay()); }
|
||||
ngOnDestroy() { clearTimeout(this.timer); }
|
||||
}
|
||||
12
src/app/shared/ui/spinner/spinner.stories.ts
Normal file
12
src/app/shared/ui/spinner/spinner.stories.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { SpinnerComponent } from './spinner.component';
|
||||
|
||||
const meta: Meta<SpinnerComponent> = {
|
||||
title: 'Atoms/Spinner',
|
||||
component: SpinnerComponent,
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<SpinnerComponent>;
|
||||
|
||||
// delay 0 so it shows immediately in the story
|
||||
export const Default: Story = { args: { delay: 0 } };
|
||||
18
src/app/shared/ui/status-badge/status-badge.component.ts
Normal file
18
src/app/shared/ui/status-badge/status-badge.component.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
|
||||
/** Atom: a coloured dot + label. Purely presentational and domain-free — the
|
||||
caller decides what colour and label mean (e.g. via registration.policy).
|
||||
This keeps the shared UI kernel free of any domain knowledge. */
|
||||
@Component({
|
||||
selector: 'app-status-badge',
|
||||
template: `
|
||||
<span style="display:inline-flex;align-items:center;gap:0.5rem">
|
||||
<span class="rhc-dot-badge" [style.background-color]="color()" aria-hidden="true"></span>
|
||||
<span>{{ label() }}</span>
|
||||
</span>
|
||||
`,
|
||||
})
|
||||
export class StatusBadgeComponent {
|
||||
label = input.required<string>();
|
||||
color = input.required<string>();
|
||||
}
|
||||
13
src/app/shared/ui/status-badge/status-badge.stories.ts
Normal file
13
src/app/shared/ui/status-badge/status-badge.stories.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { StatusBadgeComponent } from './status-badge.component';
|
||||
|
||||
const meta: Meta<StatusBadgeComponent> = {
|
||||
title: 'Shared UI/Status Badge',
|
||||
component: StatusBadgeComponent,
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<StatusBadgeComponent>;
|
||||
|
||||
export const Geregistreerd: Story = { args: { label: 'Geregistreerd', color: 'var(--rhc-color-groen-500)' } };
|
||||
export const Geschorst: Story = { args: { label: 'Geschorst', color: 'var(--rhc-color-oranje-500)' } };
|
||||
export const Doorgehaald: Story = { args: { label: 'Doorgehaald', color: 'var(--rhc-color-rood-500)' } };
|
||||
40
src/app/shared/ui/text-input/text-input.component.ts
Normal file
40
src/app/shared/ui/text-input/text-input.component.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Component, forwardRef, input } from '@angular/core';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
|
||||
/** Atom: text input. Utrecht textbox wired up as a form control (ngModel/reactive). */
|
||||
@Component({
|
||||
selector: 'app-text-input',
|
||||
template: `
|
||||
<input
|
||||
class="utrecht-textbox rhc-text-input"
|
||||
[class.utrecht-textbox--invalid]="invalid()"
|
||||
[type]="type()"
|
||||
[id]="inputId()"
|
||||
[placeholder]="placeholder()"
|
||||
[disabled]="disabled"
|
||||
[value]="value"
|
||||
(input)="onInput($event)"
|
||||
(blur)="onTouched()" />
|
||||
`,
|
||||
providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => TextInputComponent), multi: true }],
|
||||
})
|
||||
export class TextInputComponent implements ControlValueAccessor {
|
||||
type = input<'text' | 'password' | 'email'>('text');
|
||||
placeholder = input('');
|
||||
invalid = input(false);
|
||||
inputId = input<string>();
|
||||
|
||||
value = '';
|
||||
disabled = false;
|
||||
onChange: (v: string) => void = () => {};
|
||||
onTouched: () => void = () => {};
|
||||
|
||||
onInput(e: Event) {
|
||||
this.value = (e.target as HTMLInputElement).value;
|
||||
this.onChange(this.value);
|
||||
}
|
||||
writeValue(v: string) { this.value = v ?? ''; }
|
||||
registerOnChange(fn: (v: string) => void) { this.onChange = fn; }
|
||||
registerOnTouched(fn: () => void) { this.onTouched = fn; }
|
||||
setDisabledState(d: boolean) { this.disabled = d; }
|
||||
}
|
||||
Reference in New Issue
Block a user