feat(WP-67): merge behandelportal into this repo as a monorepo
Restructures into apps/ssp + apps/behandelportal (two Angular projects) plus libs/shared + libs/beheer (cross-app libraries), replacing WP-61's separate sibling repo. That split had already produced real drift: a hand-vendored copy of the backend's OpenAPI doc, a shared/ui+layout tree forked and silently diverging (7 files), and beheer + the styles.scss token bridge duplicated byte-for-byte across both repos. - git mv the SSP's src/app/* into apps/ssp/; fold shared/, beheer/, environments/, the Storybook docs/*.mdx, and styles.scss into libs/shared + libs/beheer (all confirmed identical between the two repos before merging). auth stays deliberately duplicated per ADR-0002 (actor-specific, expected to diverge) - amended there. - One generated API client (libs/shared), no more vendored swagger.json. - .dependency-cruiser split into a base factory + one config per app, and Storybook into .storybook-ssp/.storybook-behandelportal - both forced by the @auth/* alias resolving to different directories per app. - SiteHeaderComponent/ShellComponent gained HEADER_NAV_ITEMS/ HEADER_ADMIN_LINKS/DEBUG_PANEL injection tokens so each app supplies its own nav/admin-links/dev-panel instead of one being hardcoded. - CLAUDE.md, ARCHITECTURE.md, dependencies.md, and ADR-0002 updated; WP-67 backlog entry documents the full decision trail. npm run ci green (lint, dep:check x2, 360 tests across ssp/ behandelportal/shared/beheer, both localized builds, backend tests, snippet + api-client drift); both dev servers, both Storybook instances, and docker compose verified working. The old sibling repo (/home/eho/repos/behandelportal) is left untouched, not deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
|
||||
type AlertType = 'info' | 'ok' | 'warning' | 'error';
|
||||
|
||||
// visually-hidden alternative for the status icon (CIBG a11y requirement).
|
||||
const ICON_LABELS: Record<AlertType, string> = {
|
||||
info: $localize`:@@alert.icon.info:Informatie`,
|
||||
ok: $localize`:@@alert.icon.ok:Gelukt`,
|
||||
warning: $localize`:@@alert.icon.warning:Waarschuwing`,
|
||||
error: $localize`:@@alert.icon.error:Foutmelding`,
|
||||
};
|
||||
|
||||
/** Atom: alert/message banner — the CIBG Huisstijl "melding"
|
||||
(designsystem.cibg.nl/componenten/meldingen). Thin wrapper over the vendored
|
||||
`.feedback feedback-*` classes: the design system owns surface + icon; we add
|
||||
only the icon's a11y label and a content wrapper (`.feedback` is a flex row).
|
||||
Errors are `role="alert"` (assertive — interrupts) since they need immediate
|
||||
attention; other variants stay `role="status"` (polite) so success/info banners
|
||||
don't interrupt what the user is doing. */
|
||||
@Component({
|
||||
selector: 'app-alert',
|
||||
styles: [
|
||||
`
|
||||
.feedback > div {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<div
|
||||
class="feedback"
|
||||
[class.feedback-info]="type() === 'info'"
|
||||
[class.feedback-success]="type() === 'ok'"
|
||||
[class.feedback-warning]="type() === 'warning'"
|
||||
[class.feedback-error]="type() === 'error'"
|
||||
[attr.role]="type() === 'error' ? 'alert' : 'status'"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<span class="icon"
|
||||
><span class="visually-hidden">{{ iconLabels[type()] }}</span></span
|
||||
>
|
||||
<div><ng-content /></div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class AlertComponent {
|
||||
type = input<AlertType>('info');
|
||||
protected readonly iconLabels = ICON_LABELS;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { expect, within } from 'storybook/test';
|
||||
import { AlertComponent } from './alert.component';
|
||||
|
||||
const meta: Meta<AlertComponent> = {
|
||||
title: 'Design System/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>;
|
||||
|
||||
// role assertions guard the polite/assertive split (WP-16): errors interrupt, others don't.
|
||||
export const Info: Story = {
|
||||
args: { type: 'info' },
|
||||
play: async ({ canvasElement }) => {
|
||||
await expect(within(canvasElement).getByRole('status')).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
export const Ok: Story = {
|
||||
args: { type: 'ok' },
|
||||
play: async ({ canvasElement }) => {
|
||||
await expect(within(canvasElement).getByRole('status')).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
export const Warning: Story = {
|
||||
args: { type: 'warning' },
|
||||
play: async ({ canvasElement }) => {
|
||||
await expect(within(canvasElement).getByRole('status')).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
export const Error: Story = {
|
||||
args: { type: 'error' },
|
||||
play: async ({ canvasElement }) => {
|
||||
await expect(within(canvasElement).getByRole('alert')).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
// CIBG-GAP EXTENSION: Aanvragen (non-navigating row) — the vendored
|
||||
// `.dashboard-block.applications li a` chain only styles `<a>`; `.static-row`
|
||||
// mirrors it from tokens for the non-navigating case, see cibg-gaps.mdx.
|
||||
/** Molecule: one row in a CIBG Huisstijl "aanvragen" list
|
||||
(designsystem.cibg.nl/componenten/aanvragen) — a white card-link styled by the
|
||||
vendored `.dashboard-block.applications li a` chain (bg, chevron, link-blue `h3`),
|
||||
with an optional `.subtitle`/`.status`/`.cta`. Used on an `<li>` so the `<ul>`'s
|
||||
direct child is a native `<li>` (keeps the list axe-clean — a bare custom element
|
||||
between `<ul>` and its `<li>` trips axe's list rule regardless of `display:contents`).
|
||||
A non-navigating row renders a `<div>` (the vendored chain only styles `<a>`, so
|
||||
that surface is mirrored from tokens). A `[applicationActions]` slot projects a
|
||||
sibling action after the anchor — a button can't nest inside the anchor itself. */
|
||||
@Component({
|
||||
selector: 'li[app-application-link]',
|
||||
imports: [RouterLink, NgTemplateOutlet],
|
||||
styles: [
|
||||
`
|
||||
/* The vendored .applications li a surface only styles <a>; mirror it from tokens
|
||||
for a non-navigating (informational) row so the card looks consistent. */
|
||||
.static-row {
|
||||
display: flex;
|
||||
background: var(--rhc-color-wit);
|
||||
border-block-end: 0.065rem solid var(--rhc-color-border-subtle);
|
||||
padding: 0.75rem 2rem 0.75rem 1rem;
|
||||
}
|
||||
.content {
|
||||
flex: 1 1 auto;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
.cta {
|
||||
margin-inline-start: auto;
|
||||
align-self: center;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
@if (to()) {
|
||||
<a [routerLink]="to()"><ng-container [ngTemplateOutlet]="body" /></a>
|
||||
} @else if (clickable()) {
|
||||
<a href="#" (click)="onActivate($event)"><ng-container [ngTemplateOutlet]="body" /></a>
|
||||
} @else {
|
||||
<div class="static-row"><ng-container [ngTemplateOutlet]="body" /></div>
|
||||
}
|
||||
<ng-content select="[applicationActions]" />
|
||||
<ng-template #body>
|
||||
<div class="content">
|
||||
<!-- Raw <h3>, not <app-heading>: the vendored ".applications li a h3" chain styles
|
||||
the bare h3 (link-blue); an app-heading host wrapper would sit between and can
|
||||
break that selector. Documented in atomic-design.mdx (convergence decisions). -->
|
||||
<h3 class="h3">{{ heading() }}</h3>
|
||||
@if (subtitle()) {
|
||||
<div class="subtitle">{{ subtitle() }}</div>
|
||||
}
|
||||
@if (status()) {
|
||||
<div class="status">{{ status() }}</div>
|
||||
}
|
||||
</div>
|
||||
@if (cta()) {
|
||||
<div class="cta">{{ cta() }}</div>
|
||||
}
|
||||
</ng-template>
|
||||
`,
|
||||
})
|
||||
export class ApplicationLinkComponent {
|
||||
heading = input.required<string>();
|
||||
subtitle = input('');
|
||||
status = input('');
|
||||
cta = input('');
|
||||
/** Set for a plain routerLink navigation (e.g. the "Wat wilt u doen?" actions). */
|
||||
to = input('');
|
||||
/** Set when the row navigates imperatively (e.g. resume with query params) — the
|
||||
row still renders as a clickable `<a>`, but `activate` decides what happens. */
|
||||
clickable = input(false);
|
||||
activate = output<void>();
|
||||
|
||||
protected onActivate(ev: Event) {
|
||||
ev.preventDefault(); // fragment href resolves against <base href>, not the route
|
||||
this.activate.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { applicationConfig } from '@storybook/angular';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { ApplicationLinkComponent } from './application-link.component';
|
||||
|
||||
const meta: Meta<ApplicationLinkComponent> = {
|
||||
title: 'Design System/Molecules/Application Link',
|
||||
component: ApplicationLinkComponent,
|
||||
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
// Rows are <li>s in the "aanvragen" list — a real <ul> gives them their layout.
|
||||
template: `<div class="dashboard-block applications"><ul class="list-unstyled"><li app-application-link [heading]="heading" [subtitle]="subtitle" [status]="status" [cta]="cta" [to]="to" [clickable]="clickable"></li></ul></div>`,
|
||||
}),
|
||||
parameters: {
|
||||
cibgGap: true,
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'CIBG-gap extension (non-navigating row only, see NietInteractief) — see Foundations/CIBG Gap Register.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<ApplicationLinkComponent>;
|
||||
|
||||
export const Navigatie: Story = {
|
||||
args: {
|
||||
heading: 'Inschrijven',
|
||||
subtitle: 'Schrijf u in in het BIG-register.',
|
||||
to: '/registreren',
|
||||
},
|
||||
};
|
||||
export const Actie: Story = {
|
||||
args: { heading: 'Inschrijving', status: 'Stap 2 van 3', cta: 'Verder gaan', clickable: true },
|
||||
};
|
||||
export const NietInteractief: Story = {
|
||||
args: { heading: 'Herregistratie', status: 'Referentie 2024-00123 · ingediend op 12 mei 2024' },
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
/** Molecule: wraps `<app-application-link>` rows in the CIBG Huisstijl "aanvragen"
|
||||
dashboard block (`.dashboard-block.applications`) — see
|
||||
designsystem.cibg.nl/componenten/aanvragen. Used for both the "Mijn aanvragen"
|
||||
list and the "Wat wilt u doen?" action list on the dashboard. */
|
||||
@Component({
|
||||
selector: 'app-application-list',
|
||||
template: `
|
||||
<div class="dashboard-block applications">
|
||||
<ul class="list-unstyled">
|
||||
<ng-content />
|
||||
</ul>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ApplicationListComponent {}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { applicationConfig, moduleMetadata } from '@storybook/angular';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { ApplicationListComponent } from './application-list.component';
|
||||
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component';
|
||||
|
||||
const meta: Meta<ApplicationListComponent> = {
|
||||
title: 'Design System/Molecules/Application List',
|
||||
component: ApplicationListComponent,
|
||||
decorators: [
|
||||
applicationConfig({ providers: [provideRouter([])] }),
|
||||
moduleMetadata({ imports: [ApplicationLinkComponent] }),
|
||||
],
|
||||
render: () => ({
|
||||
template: `
|
||||
<app-application-list>
|
||||
<li app-application-link heading="Herregistratie" subtitle="Verlenging van uw BIG-registratie" status="In behandeling · Referentie 2024-00123 · ingediend op 12 mei 2024" to="/aanvraag/1"></li>
|
||||
<li app-application-link heading="Inschrijving" subtitle="Inschrijving in het BIG-register" status="Goedgekeurd · Referentie 2024-00088" to="/aanvraag/2"></li>
|
||||
<li app-application-link heading="Inschrijven" subtitle="Schrijf u in in het BIG-register." cta="Start inschrijving" to="/registreren"></li>
|
||||
</app-application-list>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<ApplicationListComponent>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
Component,
|
||||
Directive,
|
||||
TemplateRef,
|
||||
computed,
|
||||
contentChild,
|
||||
input,
|
||||
output,
|
||||
} 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>. Generic so the
|
||||
$implicit context is typed as the resource's T instead of unknown — see
|
||||
AsyncComponent's contentChild<AsyncLoadedDirective<T>> below, which threads the
|
||||
host's own T through the query result type. */
|
||||
@Directive({ selector: '[appAsyncLoaded]' })
|
||||
export class AsyncLoadedDirective<T = unknown> {
|
||||
constructor(public tpl: TemplateRef<{ $implicit: T }>) {}
|
||||
|
||||
static ngTemplateContextGuard<T>(
|
||||
_dir: AsyncLoadedDirective<T>,
|
||||
_ctx: unknown,
|
||||
): _ctx is { $implicit: T } {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@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: `
|
||||
<div aria-live="polite" [attr.aria-busy]="rd().tag === 'Loading' ? 'true' : null">
|
||||
@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">{{ errorText() }}</app-alert>
|
||||
<div style="margin-top:1rem">
|
||||
<app-button variant="secondary" (click)="retry()">{{ retryText() }}</app-button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@case ('Empty') {
|
||||
@if (emptyTpl()) {
|
||||
<ng-container [ngTemplateOutlet]="emptyTpl()!.tpl" />
|
||||
} @else {
|
||||
<p>{{ emptyText() }}</p>
|
||||
}
|
||||
}
|
||||
@case ('Success') {
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="loadedTpl().tpl"
|
||||
[ngTemplateOutletContext]="{ $implicit: value() }"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
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);
|
||||
|
||||
// Shared/English component: copy lives behind language-agnostic inputs. Defaults are
|
||||
// localizable via $localize (source = default locale, currently nl); callers may override.
|
||||
errorText = input($localize`:@@async.error:Er ging iets mis bij het laden van de gegevens.`);
|
||||
retryText = input($localize`:@@async.retry:Opnieuw proberen`);
|
||||
emptyText = input($localize`:@@async.empty:Geen gegevens gevonden.`);
|
||||
|
||||
loadedTpl = contentChild.required<AsyncLoadedDirective<T>>(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,
|
||||
}),
|
||||
);
|
||||
|
||||
// [resource]-fed callers get reload() for free. [data]-fed callers (a store's
|
||||
// combined RemoteData — the component doesn't own that resource) must reload
|
||||
// it themselves; retryClicked is how they find out a retry was requested.
|
||||
retryClicked = output<void>();
|
||||
retry = () => {
|
||||
const r = this.resource();
|
||||
if (r && 'reload' in r && typeof (r as { reload?: unknown }).reload === 'function') {
|
||||
(r as { reload: () => void }).reload();
|
||||
}
|
||||
this.retryClicked.emit();
|
||||
};
|
||||
}
|
||||
|
||||
/** Convenience: import this array to get the wrapper + all slot directives. */
|
||||
export const ASYNC = [
|
||||
AsyncComponent,
|
||||
AsyncLoadedDirective,
|
||||
AsyncLoadingDirective,
|
||||
AsyncEmptyDirective,
|
||||
AsyncErrorDirective,
|
||||
] as const;
|
||||
@@ -0,0 +1,47 @@
|
||||
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: 'Design System/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')) },
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
|
||||
type Variant = 'primary' | 'secondary' | 'subtle' | 'danger' | 'ghost';
|
||||
|
||||
/** Atom: button. Thin wrapper over the CIBG/Bootstrap button CSS. */
|
||||
@Component({
|
||||
selector: 'app-button',
|
||||
template: `
|
||||
<button
|
||||
[type]="type()"
|
||||
[disabled]="disabled()"
|
||||
class="btn"
|
||||
[class.btn-primary]="variant() === 'primary'"
|
||||
[class.btn-secondary]="variant() === 'secondary'"
|
||||
[class.btn-link]="variant() === 'subtle'"
|
||||
[class.btn-danger]="variant() === 'danger'"
|
||||
[class.btn-ghost]="variant() === 'ghost'"
|
||||
>
|
||||
<ng-content />
|
||||
</button>
|
||||
`,
|
||||
})
|
||||
export class ButtonComponent {
|
||||
variant = input<Variant>('primary');
|
||||
type = input<'button' | 'submit'>('button');
|
||||
disabled = input(false);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { ButtonComponent } from './button.component';
|
||||
|
||||
const meta: Meta<ButtonComponent> = {
|
||||
title: 'Design System/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 Ghost: Story = { args: { variant: 'ghost' } };
|
||||
export const Disabled: Story = { args: { variant: 'primary', disabled: true } };
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Component, computed, forwardRef, input } from '@angular/core';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
|
||||
// Per-instance fallback ids, so the label's `for` always targets THIS checkbox. The
|
||||
// CIBG styled checkbox hides the native input and routes clicks through the label, so a
|
||||
// shared/undefined id silently makes every label toggle the first input — hence a default.
|
||||
let nextCheckboxId = 0;
|
||||
|
||||
/** Atom: a labelled checkbox wired as a form control (ngModel/reactive). Thin
|
||||
wrapper over the CIBG Huisstijl `.form-check.styled` checkbox CSS; native
|
||||
input for full keyboard + screen-reader support. */
|
||||
@Component({
|
||||
selector: 'app-checkbox',
|
||||
template: `
|
||||
<div class="form-check styled">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
[id]="resolvedId()"
|
||||
[checked]="value"
|
||||
[disabled]="disabled"
|
||||
(change)="onToggle($event)"
|
||||
(blur)="onTouched()"
|
||||
/>
|
||||
<label class="form-check-label" [for]="resolvedId()">{{ label() }}</label>
|
||||
</div>
|
||||
`,
|
||||
providers: [
|
||||
{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => CheckboxComponent), multi: true },
|
||||
],
|
||||
})
|
||||
export class CheckboxComponent implements ControlValueAccessor {
|
||||
checkboxId = input<string>();
|
||||
label = input('');
|
||||
|
||||
/** The caller's id, or a unique fallback — never undefined, so labels never collide. */
|
||||
private autoId = `app-checkbox-${nextCheckboxId++}`;
|
||||
protected resolvedId = computed(() => this.checkboxId() ?? this.autoId);
|
||||
|
||||
value = false;
|
||||
disabled = false;
|
||||
onChange: (v: boolean) => void = () => {};
|
||||
onTouched: () => void = () => {};
|
||||
|
||||
onToggle(e: Event) {
|
||||
this.value = (e.target as HTMLInputElement).checked;
|
||||
this.onChange(this.value);
|
||||
}
|
||||
writeValue(v: boolean) {
|
||||
this.value = !!v;
|
||||
}
|
||||
registerOnChange(fn: (v: boolean) => void) {
|
||||
this.onChange = fn;
|
||||
}
|
||||
registerOnTouched(fn: () => void) {
|
||||
this.onTouched = fn;
|
||||
}
|
||||
setDisabledState(d: boolean) {
|
||||
this.disabled = d;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { CheckboxComponent } from './checkbox.component';
|
||||
|
||||
const meta: Meta<CheckboxComponent> = {
|
||||
title: 'Design System/Atoms/Checkbox',
|
||||
component: CheckboxComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-checkbox [label]="label" [checkboxId]="checkboxId"></app-checkbox>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<CheckboxComponent>;
|
||||
|
||||
export const Default: Story = { args: { label: 'Standaard aanhef', checkboxId: 'cb-1' } };
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
/** Molecule: one choice in a CIBG Huisstijl "keuzelijst" — `<li><div class="keuzelijst__link">`
|
||||
with a title and optional instructions (see choice-list.component.ts). Renders a
|
||||
plain (non-interactive) block when there's nothing to navigate to.
|
||||
|
||||
The title is a Bootstrap "stretched-link" (`.stretched-link`, vendored) rather than
|
||||
the whole box being an `<a>`: a `[choiceActions]` slot needs to project a sibling
|
||||
action (e.g. "Annuleren") *inside* the same card, and a `<button>` can't nest
|
||||
inside an `<a>` (invalid HTML, broken a11y). stretched-link keeps the entire card
|
||||
clickable via its `::after` overlay; the projected action sits above that overlay
|
||||
(see its own `position:relative;z-index:2` at the call site) so it stays clickable.
|
||||
|
||||
Unlike the "aanvragen" pattern's `.applications li a::after` (scoped to the `a`
|
||||
tag), CIBG's `.keuzelijst__link:after`/`:hover`/`:focus` rules key off the bare
|
||||
class — keuzelijst assumes every item IS a link — so a non-interactive row would
|
||||
otherwise inherit the chevron and hover accent too. The `--static` modifier below
|
||||
suppresses both for that case. `:focus-within` restores the focus accent that
|
||||
`:focus` would have given the (no longer directly focused) card. */
|
||||
@Component({
|
||||
selector: 'app-choice-link',
|
||||
imports: [RouterLink],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
.keuzelijst__link {
|
||||
position: relative;
|
||||
}
|
||||
.keuzelijst__link:focus-within {
|
||||
background-color: var(--rhc-color-cool-grey-100);
|
||||
box-shadow: inset 4px 0 0 0 var(--rhc-color-lintblauw-500);
|
||||
}
|
||||
.keuzelijst__link--static::after {
|
||||
content: none;
|
||||
}
|
||||
.keuzelijst__link--static:hover {
|
||||
background-color: var(--rhc-color-cool-grey-200);
|
||||
box-shadow: none;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<li class="keuzelijst__list-item">
|
||||
<div class="keuzelijst__link" [class.keuzelijst__link--static]="!to() && !clickable()">
|
||||
<h3 class="keuzelijst__header">
|
||||
@if (to()) {
|
||||
<a class="stretched-link" [routerLink]="to()">{{ heading() }}</a>
|
||||
} @else if (clickable()) {
|
||||
<a class="stretched-link" href="#" (click)="onActivate($event)">{{ heading() }}</a>
|
||||
} @else {
|
||||
{{ heading() }}
|
||||
}
|
||||
</h3>
|
||||
@if (instructions()) {
|
||||
<p class="keuzelijst__instructions">{{ instructions() }}</p>
|
||||
}
|
||||
<ng-content select="[choiceActions]" />
|
||||
</div>
|
||||
</li>
|
||||
`,
|
||||
})
|
||||
export class ChoiceLinkComponent {
|
||||
heading = input.required<string>();
|
||||
instructions = input('');
|
||||
/** Set for a plain routerLink navigation. */
|
||||
to = input('');
|
||||
/** Set when the choice navigates imperatively (e.g. resume with query params) — the
|
||||
row still renders as a clickable card, but `activate` decides what happens. */
|
||||
clickable = input(false);
|
||||
activate = output<void>();
|
||||
|
||||
protected onActivate(ev: Event) {
|
||||
ev.preventDefault(); // fragment href resolves against <base href>, not the route
|
||||
this.activate.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { applicationConfig } from '@storybook/angular';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { ChoiceLinkComponent } from './choice-link.component';
|
||||
|
||||
const meta: Meta<ChoiceLinkComponent> = {
|
||||
title: 'Design System/Molecules/Choice Link',
|
||||
component: ChoiceLinkComponent,
|
||||
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
// Rows are <li>s — a real list gives them their normal layout in the story.
|
||||
template: `<ul class="keuzelijst__list"><app-choice-link [heading]="heading" [instructions]="instructions" [to]="to" [clickable]="clickable" /></ul>`,
|
||||
}),
|
||||
parameters: {
|
||||
// Structural: app-choice-link's host sits between the <ul> and its <li> — axe's
|
||||
// list/listitem rule requires them adjacent regardless of `display:contents`.
|
||||
// WP-11 (CIBG markup fidelity) reworks this markup; see docs/project/backlog/WP-11-markup-fidelity.md.
|
||||
a11y: { disable: true },
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<ChoiceLinkComponent>;
|
||||
|
||||
export const Navigatie: Story = {
|
||||
args: {
|
||||
heading: 'Ik heb een Nederlands diploma',
|
||||
instructions: 'U kunt direct uw registratie aanvragen.',
|
||||
to: '/registreren',
|
||||
},
|
||||
};
|
||||
export const Actie: Story = {
|
||||
args: { heading: 'Inschrijving', instructions: 'Stap 2 van 3', clickable: true },
|
||||
};
|
||||
export const NietInteractief: Story = {
|
||||
args: {
|
||||
heading: 'Herregistratie',
|
||||
instructions: 'Referentie 2024-00123 · ingediend op 12 mei 2024',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
/** Molecule: the CIBG Huisstijl "keuzelijst" — a heading semantically linked
|
||||
(`aria-labelledby`) to a list of `<app-choice-link>` choices, used where a
|
||||
screen offers a set of options to pick between (see
|
||||
designsystem.cibg.nl/componenten/keuzelijst). Domain-free — the caller
|
||||
supplies the heading text and the choices. */
|
||||
@Component({
|
||||
selector: 'app-choice-list',
|
||||
template: `
|
||||
<h2 class="header header--medium" [id]="headingId">{{ heading() }}</h2>
|
||||
<ul class="keuzelijst__list" [attr.aria-labelledby]="headingId">
|
||||
<ng-content />
|
||||
</ul>
|
||||
`,
|
||||
})
|
||||
export class ChoiceListComponent {
|
||||
heading = input.required<string>();
|
||||
protected readonly headingId = `choice-list-${nextId++}`;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { applicationConfig, moduleMetadata } from '@storybook/angular';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { ChoiceListComponent } from './choice-list.component';
|
||||
import { ChoiceLinkComponent } from '@shared/ui/choice-link/choice-link.component';
|
||||
|
||||
const meta: Meta<ChoiceListComponent> = {
|
||||
title: 'Design System/Molecules/Choice List',
|
||||
component: ChoiceListComponent,
|
||||
decorators: [
|
||||
applicationConfig({ providers: [provideRouter([])] }),
|
||||
moduleMetadata({ imports: [ChoiceLinkComponent] }),
|
||||
],
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<app-choice-list [heading]="heading">
|
||||
<app-choice-link heading="Ik heb een Nederlands diploma" instructions="U kunt direct uw registratie aanvragen." to="/registreren" />
|
||||
<app-choice-link heading="Ik heb een buitenlands diploma" instructions="Uw diploma moet eerst officieel erkend worden." clickable="true" />
|
||||
</app-choice-list>`,
|
||||
}),
|
||||
parameters: {
|
||||
// Structural: app-choice-link's host sits between the <ul> and its <li> —
|
||||
// fixed by the WP-11 markup rework. See docs/project/backlog/WP-11-markup-fidelity.md.
|
||||
a11y: { disable: true },
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<ChoiceListComponent>;
|
||||
|
||||
export const Default: Story = { args: { heading: 'Maak een keuze' } };
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
|
||||
/** Molecule: the CIBG Huisstijl "bevestiging" (confirmation) — an animated green
|
||||
checkmark banner shown at the end of an aanvraagproces, ONLY when the user has
|
||||
nothing left to do (see designsystem.cibg.nl/componenten/bevestiging). Follow-up
|
||||
content (a reference number, a restart button) is projected below the banner. */
|
||||
@Component({
|
||||
selector: 'app-confirmation',
|
||||
template: `
|
||||
<div class="confirmation">
|
||||
<svg
|
||||
class="confirmation__checkmark"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 52 52"
|
||||
height="52"
|
||||
width="52"
|
||||
>
|
||||
<circle class="confirmation__checkmark-circle" cx="26" cy="26" r="18" fill="none" />
|
||||
<path class="confirmation__checkmark-check" fill="none" d="M14.1 27.2l7.1 7.2 16.7-16.8" />
|
||||
</svg>
|
||||
<div class="confirmation__title">
|
||||
<span class="visually-hidden">{{ successPrefix() }}</span
|
||||
>{{ title() }}
|
||||
</div>
|
||||
</div>
|
||||
<ng-content />
|
||||
`,
|
||||
})
|
||||
export class ConfirmationComponent {
|
||||
title = input.required<string>();
|
||||
successPrefix = input($localize`:@@confirmation.succes:Succes:`);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { ConfirmationComponent } from './confirmation.component';
|
||||
|
||||
const meta: Meta<ConfirmationComponent> = {
|
||||
title: 'Design System/Molecules/Confirmation',
|
||||
component: ConfirmationComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<app-confirmation [title]="title">
|
||||
<p class="app-section">Uw referentienummer is 2024-00123. Bewaar dit nummer voor uw administratie.</p>
|
||||
</app-confirmation>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<ConfirmationComponent>;
|
||||
|
||||
export const Default: Story = { args: { title: 'Uw aanvraag is verstuurd' } };
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
|
||||
/** Molecule: CIBG Huisstijl **Datablock** (designsystem.cibg.nl/componenten/datablock)
|
||||
— THE way to show user/application data. A grey `.data-block` surface holds a white
|
||||
`.block-wrapper` panel with a `<dl>` of projected `<app-data-row>`s. Use `stacked`
|
||||
(`.data-block--stacked`) when labels/values are long and should stack. This is the
|
||||
single data surface (a generic white `app-card` used to exist but was unused and
|
||||
removed — see WP-12); the datablock carries its own surface, so it is not nested in
|
||||
another one. When there is no visible `heading`, pass an `ariaLabel` so the definition
|
||||
list is announced. */
|
||||
@Component({
|
||||
selector: 'app-data-block',
|
||||
imports: [HeadingComponent],
|
||||
template: `
|
||||
@if (heading()) {
|
||||
<app-heading [level]="level()">{{ heading() }}</app-heading>
|
||||
}
|
||||
<div class="data-block" [class.data-block--stacked]="stacked()">
|
||||
<div class="block-wrapper">
|
||||
<dl class="mb-0" [attr.aria-label]="ariaLabel() || null">
|
||||
<ng-content />
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class DataBlockComponent {
|
||||
heading = input('');
|
||||
/** Heading level when `heading` is set (default h3). */
|
||||
level = input<1 | 2 | 3 | 4 | 5>(3);
|
||||
/** Stacks label above value (`.data-block--stacked`) for long content. */
|
||||
stacked = input(false);
|
||||
/** Accessible name for the `<dl>` when there is no visible heading. */
|
||||
ariaLabel = input('');
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { moduleMetadata } from '@storybook/angular';
|
||||
import { DataBlockComponent } from './data-block.component';
|
||||
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
|
||||
|
||||
const meta: Meta<DataBlockComponent> = {
|
||||
title: 'Design System/Molecules/Data Block',
|
||||
component: DataBlockComponent,
|
||||
decorators: [moduleMetadata({ imports: [DataRowComponent] })],
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<app-data-block [heading]="heading" [stacked]="stacked" [ariaLabel]="ariaLabel">
|
||||
<div app-data-row key="BIG-nummer" value="19012345601"></div>
|
||||
<div app-data-row key="Naam" value="J. de Vries"></div>
|
||||
<div app-data-row key="Beroep" value="Verpleegkundige"></div>
|
||||
<div app-data-row key="Registratiedatum" value="1 maart 2018"></div>
|
||||
</app-data-block>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<DataBlockComponent>;
|
||||
|
||||
export const Default: Story = { args: { heading: 'Persoonsgegevens (BRP)' } };
|
||||
export const ZonderKop: Story = { args: { ariaLabel: 'Registratiegegevens' } };
|
||||
export const Stacked: Story = { args: { heading: 'Toelichting', stacked: true } };
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
|
||||
/** Molecule: one key/value row inside a CIBG Huisstijl **Datablock** (see
|
||||
`data-block.component.ts`) — the row primitive of the datablock/`controlestap`
|
||||
data summary. Used on a `<div>` so the `<dl>`'s direct child is a native element
|
||||
(the HTML5.1 `dl > div > dt + dd` grouping), which keeps the definition list
|
||||
axe-clean — a bare custom element between `<dl>` and its `<dt>/<dd>` trips axe's
|
||||
definition-list rule regardless of `display:contents`. The host is the Bootstrap
|
||||
`.row`; `dt.col-md-4`/`dd.col-md-8` give the label/value widths. Wrap several in a
|
||||
`<dl class="mb-0">` (a `<app-data-block>`). Project custom content (e.g. a badge)
|
||||
into the `<dd>` via `<ng-content>`. */
|
||||
@Component({
|
||||
selector: 'div[app-data-row]',
|
||||
host: { class: 'row' },
|
||||
// The CIBG datablock draws a separator between entries. It ships that border on
|
||||
// dt/dd with a `:last-of-type` reset, but our one-row-per-<div> grouping (for axe)
|
||||
// makes every dt/dd a last-of-type — so we carry the separator on the row instead.
|
||||
styles: [
|
||||
`
|
||||
:host:not(:last-of-type) {
|
||||
border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-cool-grey-200);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<dt class="col-md-4">{{ key() }}</dt>
|
||||
<dd class="col-md-8">
|
||||
<ng-content>{{ value() }}</ng-content>
|
||||
</dd>
|
||||
`,
|
||||
})
|
||||
export class DataRowComponent {
|
||||
key = input.required<string>();
|
||||
value = input<string | null>('');
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { DataRowComponent } from './data-row.component';
|
||||
|
||||
const meta: Meta<DataRowComponent> = {
|
||||
title: 'Design System/Molecules/Data Row',
|
||||
component: DataRowComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
// A row is a `.row` <div> grouping dt.col-md-4/dd.col-md-8 inside the datablock <dl>
|
||||
// (HTML5.1 dl > div > dt+dd — a native div child keeps the definition list axe-clean).
|
||||
template: `<dl class="mb-0"><div app-data-row [key]="key" [value]="value"></div></dl>`,
|
||||
}),
|
||||
args: { key: 'BIG-nummer', value: '19012345601' },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<DataRowComponent>;
|
||||
|
||||
export const Default: Story = {};
|
||||
export const Empty: Story = { args: { key: 'Tweede naam', value: '' } };
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Component, booleanAttribute, input } from '@angular/core';
|
||||
|
||||
/** Molecule: form field = label + projected control + optional error/description,
|
||||
in the CIBG Huisstijl horizontal `form-group row` layout (label `col-md-4`,
|
||||
control `col-md-8`). The required asterisk comes from
|
||||
`.form-group.required>.col-form-label::after` — no separate "(verplicht)" text.
|
||||
Reused by the login form, the change-request form, and every wizard step. */
|
||||
@Component({
|
||||
selector: 'app-form-field',
|
||||
template: `
|
||||
<div class="form-group row" [class.required]="required()">
|
||||
<label class="col-md-4 col-form-label" [id]="fieldId() + '-label'" [for]="fieldId()">{{
|
||||
label()
|
||||
}}</label>
|
||||
<div class="col-md-8 col-control">
|
||||
@if (description()) {
|
||||
<div class="form-text" [id]="fieldId() + '-desc'">{{ description() }}</div>
|
||||
}
|
||||
<ng-content />
|
||||
@if (error()) {
|
||||
<div [id]="fieldId() + '-error'" role="alert">
|
||||
<span class="errortext">{{ error() }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class FormFieldComponent {
|
||||
label = input.required<string>();
|
||||
fieldId = input.required<string>();
|
||||
description = input<string>();
|
||||
error = input<string>();
|
||||
required = input(false, { transform: booleanAttribute });
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { moduleMetadata } from '@storybook/angular';
|
||||
import { expect, within } from 'storybook/test';
|
||||
import { FormFieldComponent } from './form-field.component';
|
||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||
|
||||
const meta: Meta<FormFieldComponent> = {
|
||||
title: 'Design System/Molecules/Form Field',
|
||||
component: FormFieldComponent,
|
||||
decorators: [moduleMetadata({ imports: [TextInputComponent] })],
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
// form-horizontal + .row context, same as every real caller (wizard-shell, login-form, …).
|
||||
template: `
|
||||
<form class="form-horizontal">
|
||||
<app-form-field [label]="label" [fieldId]="fieldId" [description]="description" [error]="error" [required]="required">
|
||||
<app-text-input [inputId]="fieldId" [hasDescription]="!!description" [invalid]="!!error" placeholder="Vul in" />
|
||||
</app-form-field>
|
||||
</form>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<FormFieldComponent>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: { label: 'BSN', fieldId: 'bsn', description: '9 cijfers', required: true },
|
||||
// Composition contract: fieldId must equal the input's id — enforced here, not by DI
|
||||
// (see WP-16). Catches drift in the description→aria-describedby wiring.
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const input = canvas.getByRole('textbox');
|
||||
await expect(input).toHaveAttribute('aria-describedby', 'bsn-desc');
|
||||
},
|
||||
};
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
label: 'Straat en huisnummer',
|
||||
fieldId: 'street',
|
||||
error: 'Dit veld is verplicht.',
|
||||
required: true,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const input = canvas.getByRole('textbox');
|
||||
await expect(input).toHaveAttribute('aria-describedby', 'street-error');
|
||||
},
|
||||
};
|
||||
export const WithDescriptionAndError: Story = {
|
||||
args: {
|
||||
label: 'BSN',
|
||||
fieldId: 'bsn',
|
||||
description: '9 cijfers',
|
||||
error: 'Ongeldig BSN.',
|
||||
required: true,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const input = canvas.getByRole('textbox');
|
||||
await expect(input).toHaveAttribute('aria-describedby', 'bsn-desc bsn-error');
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
|
||||
/** Atom: heading. Renders the right h1..h5; CIBG/Bootstrap styles native headings.
|
||||
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><ng-container [ngTemplateOutlet]="content" /></h1>
|
||||
}
|
||||
@case (2) {
|
||||
<h2><ng-container [ngTemplateOutlet]="content" /></h2>
|
||||
}
|
||||
@case (3) {
|
||||
<h3><ng-container [ngTemplateOutlet]="content" /></h3>
|
||||
}
|
||||
@case (4) {
|
||||
<h4><ng-container [ngTemplateOutlet]="content" /></h4>
|
||||
}
|
||||
@default {
|
||||
<h5><ng-container [ngTemplateOutlet]="content" /></h5>
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class HeadingComponent {
|
||||
level = input<1 | 2 | 3 | 4 | 5>(2);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { HeadingComponent } from './heading.component';
|
||||
|
||||
const meta: Meta<HeadingComponent> = {
|
||||
title: 'Design System/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 } };
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
/** Atom: link. Internal router link; CIBG/Bootstrap styles bare anchors. */
|
||||
@Component({
|
||||
selector: 'app-link',
|
||||
imports: [RouterLink],
|
||||
template: `<a [routerLink]="to()" class="link-primary"><ng-content /></a>`,
|
||||
})
|
||||
export class LinkComponent {
|
||||
to = input.required<string>();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { applicationConfig } from '@storybook/angular';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { LinkComponent } from './link.component';
|
||||
|
||||
const meta: Meta<LinkComponent> = {
|
||||
title: 'Design System/Atoms/Link',
|
||||
component: LinkComponent,
|
||||
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-link [to]="to">Naar het dashboard</app-link>`,
|
||||
}),
|
||||
args: { to: '/dashboard' },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<LinkComponent>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Component, computed, input, output } from '@angular/core';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
|
||||
/**
|
||||
* Atom: a possibly-masked sensitive value (BSN, BIG-nummer, …) with an optional, audited
|
||||
* reveal affordance (WP-40). The value arrives masked from the server (data-minimisation)
|
||||
* and is swapped for the full value on reveal; the reveal button shows only when the value
|
||||
* is still masked AND the caller says the principal may reveal it. Centralises the
|
||||
* masked-detection that consumers used to sniff inline. The atom only emits `reveal`; the
|
||||
* caller owns the step-up gesture + the audited fetch (see behandel-scherm).
|
||||
*
|
||||
* ponytail: masked-detection is the mask character (`*`) — a POC heuristic. A server-sent
|
||||
* `masked` boolean would remove the sniff; wire it here without touching consumers.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-masked-value',
|
||||
imports: [ButtonComponent],
|
||||
template: `
|
||||
<span class="value">{{ value() }}</span>
|
||||
@if (canReveal() && masked()) {
|
||||
<app-button variant="subtle" (click)="reveal.emit()">{{ revealLabel() }}</app-button>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class MaskedValueComponent {
|
||||
value = input.required<string>();
|
||||
canReveal = input(false);
|
||||
revealLabel = input($localize`:@@maskedValue.reveal:Tonen`);
|
||||
reveal = output<void>();
|
||||
|
||||
protected masked = computed(() => this.value().includes('*'));
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { MaskedValueComponent } from './masked-value.component';
|
||||
|
||||
const meta: Meta<MaskedValueComponent> = {
|
||||
title: 'Design System/Atoms/Masked Value',
|
||||
component: MaskedValueComponent,
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<MaskedValueComponent>;
|
||||
|
||||
/** Masked + the principal may reveal → the reveal button shows. */
|
||||
export const RevealableMasked: Story = {
|
||||
args: { value: '******601', canReveal: true, revealLabel: 'Toon BIG-nummer' },
|
||||
};
|
||||
|
||||
/** Masked but no reveal right → just the masked value, no affordance. */
|
||||
export const MaskedNoReveal: Story = {
|
||||
args: { value: '******601', canReveal: false },
|
||||
};
|
||||
|
||||
/** Already revealed (no mask character) → no reveal button even with the right. */
|
||||
export const Revealed: Story = {
|
||||
args: { value: '990000000012', canReveal: true },
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Component, computed, input } from '@angular/core';
|
||||
|
||||
// CIBG-GAP EXTENSION: n/a — no vendored inline-chip/tag class; hand-rolled
|
||||
// brace-wrapped chip, see cibg-gaps.mdx.
|
||||
/** Atom: a highlighted, non-editable placeholder chip for READ-ONLY rendering
|
||||
(preview, diagnostics). Distinct styling for auto-resolvable vs manual fields and
|
||||
for linter error/warning states. Domain-free and presentational — the caller
|
||||
passes label/state; a11y label announces the field name + its resolution status.
|
||||
(The editor renders its own inline chips inside contenteditable; this atom is for
|
||||
everywhere the letter is shown, not edited.) */
|
||||
@Component({
|
||||
selector: 'app-placeholder-chip',
|
||||
styles: [
|
||||
`
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2em;
|
||||
border-radius: var(--rhc-border-radius-sm);
|
||||
padding: 0 0.35em;
|
||||
line-height: 1.6;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Braces use unicode escapes; a literal { in a CSS content string breaks the style parser. */
|
||||
.chip::before {
|
||||
content: '\\7B';
|
||||
opacity: 0.6;
|
||||
font-weight: 700;
|
||||
}
|
||||
.chip::after {
|
||||
content: '\\7D';
|
||||
opacity: 0.6;
|
||||
font-weight: 700;
|
||||
}
|
||||
.chip--auto {
|
||||
background: var(--rhc-color-cool-grey-100);
|
||||
color: var(--rhc-color-foreground-default);
|
||||
}
|
||||
.chip--manual {
|
||||
background: var(--rhc-color-geel-100);
|
||||
color: var(--rhc-color-foreground-default);
|
||||
}
|
||||
.chip--warning {
|
||||
background: var(--rhc-color-geel-100);
|
||||
border-color: var(--rhc-color-border-default);
|
||||
color: var(--rhc-color-foreground-default);
|
||||
}
|
||||
.chip--error {
|
||||
background: var(--rhc-color-rood-100);
|
||||
border-color: var(--rhc-color-border-default);
|
||||
color: var(--rhc-color-foreground-default);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `<span class="chip" [class]="'chip--' + variant()" [attr.aria-label]="ariaLabel()">{{
|
||||
label()
|
||||
}}</span>`,
|
||||
})
|
||||
export class PlaceholderChipComponent {
|
||||
label = input.required<string>();
|
||||
autoResolvable = input(false);
|
||||
state = input<'ok' | 'warning' | 'error'>('ok');
|
||||
|
||||
// Copy is localizable-by-default per the shared-UI convention (like <app-async>).
|
||||
autoText = input($localize`:@@placeholderChip.auto:wordt automatisch ingevuld`);
|
||||
manualText = input($localize`:@@placeholderChip.manual:handmatig in te vullen`);
|
||||
warningText = input($localize`:@@placeholderChip.warning:let op`);
|
||||
errorText = input($localize`:@@placeholderChip.error:fout`);
|
||||
|
||||
protected variant = computed(() => {
|
||||
const s = this.state();
|
||||
return s !== 'ok' ? s : this.autoResolvable() ? 'auto' : 'manual';
|
||||
});
|
||||
|
||||
protected ariaLabel = computed(() => {
|
||||
const status = {
|
||||
auto: this.autoText(),
|
||||
manual: this.manualText(),
|
||||
warning: this.warningText(),
|
||||
error: this.errorText(),
|
||||
}[this.variant()];
|
||||
return $localize`:@@placeholderChip.aria:Veld ${this.label()}:label:, ${status}:status:`;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { PlaceholderChipComponent } from './placeholder-chip.component';
|
||||
|
||||
const meta: Meta<PlaceholderChipComponent> = {
|
||||
title: 'Design System/Atoms/Placeholder Chip',
|
||||
component: PlaceholderChipComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-placeholder-chip [label]="label" [autoResolvable]="autoResolvable" [state]="state"></app-placeholder-chip>`,
|
||||
}),
|
||||
parameters: {
|
||||
cibgGap: true,
|
||||
docs: { description: { component: 'CIBG-gap extension — see Foundations/CIBG Gap Register.' } },
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<PlaceholderChipComponent>;
|
||||
|
||||
export const AutoResolvable: Story = {
|
||||
args: { label: 'Naam zorgverlener', autoResolvable: true, state: 'ok' },
|
||||
};
|
||||
export const Manual: Story = {
|
||||
args: { label: 'Reden besluit', autoResolvable: false, state: 'ok' },
|
||||
};
|
||||
export const Warning: Story = {
|
||||
args: { label: 'Oud kenmerk', autoResolvable: true, state: 'warning' },
|
||||
};
|
||||
export const Error: Story = {
|
||||
args: { label: 'Onbekend veld', autoResolvable: false, state: 'error' },
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Component, forwardRef, input } from '@angular/core';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
|
||||
export interface RadioOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** The ubiquitous yes/no option pair. Language-agnostic now the labels are
|
||||
localized, so it lives next to RadioOption and both wizards import it. */
|
||||
export const JA_NEE: RadioOption[] = [
|
||||
{ value: 'ja', label: $localize`:@@common.ja:Ja` },
|
||||
{ value: 'nee', label: $localize`:@@common.nee:Nee` },
|
||||
];
|
||||
|
||||
/** Atom: a radio group. Thin wrapper over the CIBG Huisstijl `.form-check.styled`
|
||||
radio CSS, wired as a form control so it works with ngModel just like the
|
||||
text-input atom. */
|
||||
@Component({
|
||||
selector: 'app-radio-group',
|
||||
template: `
|
||||
<div
|
||||
role="radiogroup"
|
||||
[attr.aria-labelledby]="name() + '-label'"
|
||||
[attr.aria-invalid]="invalid() ? 'true' : null"
|
||||
[attr.aria-describedby]="invalid() ? name() + '-error' : null"
|
||||
>
|
||||
@for (opt of options(); track opt.value) {
|
||||
<div class="form-check styled">
|
||||
<input
|
||||
class="form-check-input"
|
||||
[class.is-invalid]="invalid()"
|
||||
type="radio"
|
||||
[id]="name() + '-' + opt.value"
|
||||
[name]="name()"
|
||||
[value]="opt.value"
|
||||
[checked]="value === opt.value"
|
||||
[disabled]="disabled"
|
||||
(change)="select(opt.value)"
|
||||
(blur)="onTouched()"
|
||||
/>
|
||||
<label class="form-check-label" [for]="name() + '-' + opt.value">{{ opt.label }}</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
providers: [
|
||||
{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => RadioGroupComponent), multi: true },
|
||||
],
|
||||
})
|
||||
export class RadioGroupComponent implements ControlValueAccessor {
|
||||
options = input.required<RadioOption[]>();
|
||||
name = input.required<string>();
|
||||
invalid = input(false);
|
||||
|
||||
value = '';
|
||||
disabled = false;
|
||||
onChange: (v: string) => void = () => {};
|
||||
onTouched: () => void = () => {};
|
||||
|
||||
select(v: string) {
|
||||
this.value = v;
|
||||
this.onChange(v);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { RadioGroupComponent } from './radio-group.component';
|
||||
|
||||
const meta: Meta<RadioGroupComponent> = {
|
||||
title: 'Design System/Atoms/RadioGroup',
|
||||
component: RadioGroupComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-radio-group [options]="options" [name]="name" />`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<RadioGroupComponent>;
|
||||
|
||||
export const JaNee: Story = {
|
||||
args: {
|
||||
name: 'voorbeeld',
|
||||
options: [
|
||||
{ value: 'ja', label: 'Ja' },
|
||||
{ value: 'nee', label: 'Nee' },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
|
||||
/** Molecule: one section of a CIBG Huisstijl "controlestap" (wizard review step) —
|
||||
a heading with a "Wijzigen" link, and the section's `<app-data-row>`s in a CIBG
|
||||
Datablock (composes `<app-data-block>`). Domain-free; the caller supplies the
|
||||
heading and decides what "Wijzigen" does (typically jump back to a step). */
|
||||
@Component({
|
||||
selector: 'app-review-section',
|
||||
imports: [DataBlockComponent, HeadingComponent],
|
||||
template: `
|
||||
<div class="d-flex">
|
||||
<app-heading [level]="2">{{ heading() }}</app-heading>
|
||||
@if (showEdit()) {
|
||||
<div class="ms-auto">
|
||||
<a href="#" [attr.aria-label]="editAriaLabel() || editLabel()" (click)="onEdit($event)">{{
|
||||
editLabel()
|
||||
}}</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<app-data-block><ng-content /></app-data-block>
|
||||
`,
|
||||
})
|
||||
export class ReviewSectionComponent {
|
||||
heading = input.required<string>();
|
||||
editLabel = input($localize`:@@reviewSection.wijzigen:Wijzigen`);
|
||||
editAriaLabel = input('');
|
||||
showEdit = input(true);
|
||||
|
||||
edit = output<void>();
|
||||
|
||||
protected onEdit(ev: Event) {
|
||||
ev.preventDefault(); // fragment href resolves against <base href>, not the route
|
||||
this.edit.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { moduleMetadata } from '@storybook/angular';
|
||||
import { ReviewSectionComponent } from './review-section.component';
|
||||
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
|
||||
|
||||
const meta: Meta<ReviewSectionComponent> = {
|
||||
title: 'Design System/Molecules/Review Section',
|
||||
component: ReviewSectionComponent,
|
||||
decorators: [moduleMetadata({ imports: [DataRowComponent] })],
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<app-review-section [heading]="heading" [editLabel]="editLabel" [editAriaLabel]="editAriaLabel" [showEdit]="showEdit">
|
||||
<div app-data-row key="Straat en huisnummer" value="Dorpsstraat 1"></div>
|
||||
<div app-data-row key="Postcode" value="1234 AB"></div>
|
||||
<div app-data-row key="Woonplaats" value="Utrecht"></div>
|
||||
</app-review-section>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<ReviewSectionComponent>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: { heading: 'Adres en correspondentie', editAriaLabel: 'Wijzigen adresgegevens' },
|
||||
};
|
||||
export const ZonderWijzigen: Story = {
|
||||
args: { heading: 'Adres en correspondentie', showEdit: false },
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||
import { adjacentChip, readBlock, renderInto } from './rich-text-dom';
|
||||
|
||||
const labelFor = (key: string) => ({ naam: 'Naam', datum: 'Datum' })[key] ?? key;
|
||||
|
||||
function roundTrip(block: RichTextBlock): RichTextBlock {
|
||||
const root = document.createElement('div');
|
||||
renderInto(root, block, labelFor);
|
||||
return readBlock(root);
|
||||
}
|
||||
|
||||
describe('rich-text DOM boundary', () => {
|
||||
it('round-trips text, marks, placeholders, line breaks and multiple paragraphs', () => {
|
||||
const block: RichTextBlock = {
|
||||
paragraphs: [
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Beste ' },
|
||||
{ type: 'placeholder', key: 'naam' },
|
||||
{ type: 'text', text: ' vet', marks: ['bold'] },
|
||||
{ type: 'lineBreak' },
|
||||
{ type: 'text', text: 'nieuwe regel' },
|
||||
],
|
||||
},
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Op ' },
|
||||
{ type: 'placeholder', key: 'datum' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(roundTrip(block)).toEqual(block);
|
||||
});
|
||||
|
||||
it('round-trips an empty paragraph (filler <br> is not a line break)', () => {
|
||||
const empty: RichTextBlock = { paragraphs: [{ nodes: [] }] };
|
||||
expect(roundTrip(empty)).toEqual(empty);
|
||||
});
|
||||
|
||||
it('renders a placeholder as a non-editable chip carrying its key and label', () => {
|
||||
const root = document.createElement('div');
|
||||
renderInto(root, { paragraphs: [{ nodes: [{ type: 'placeholder', key: 'naam' }] }] }, labelFor);
|
||||
const chip = root.querySelector('.rte-chip') as HTMLElement;
|
||||
expect(chip.getAttribute('contenteditable')).toBe('false');
|
||||
expect(chip.dataset['phKey']).toBe('naam');
|
||||
expect(chip.textContent).toBe('Naam');
|
||||
});
|
||||
|
||||
it('reads combined marks in canonical order regardless of nesting', () => {
|
||||
const root = document.createElement('div');
|
||||
root.innerHTML = '<p><em><strong>x</strong></em></p>'; // italic wrapping bold
|
||||
expect(readBlock(root)).toEqual({
|
||||
paragraphs: [{ nodes: [{ type: 'text', text: 'x', marks: ['bold', 'italic'] }] }],
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips bullet and numbered lists mixed with paragraphs', () => {
|
||||
const block: RichTextBlock = {
|
||||
paragraphs: [
|
||||
{ nodes: [{ type: 'text', text: 'Intro' }] },
|
||||
{ nodes: [{ type: 'text', text: 'een' }], list: 'bullet' },
|
||||
{ nodes: [{ type: 'text', text: 'twee' }], list: 'bullet' },
|
||||
{ nodes: [{ type: 'text', text: 'eerst' }], list: 'number' },
|
||||
{ nodes: [{ type: 'text', text: 'dan' }], list: 'number' },
|
||||
{ nodes: [{ type: 'text', text: 'Slot' }] },
|
||||
],
|
||||
};
|
||||
expect(roundTrip(block)).toEqual(block);
|
||||
});
|
||||
|
||||
it('groups consecutive same-kind list lines into one <ul>/<ol>', () => {
|
||||
const root = document.createElement('div');
|
||||
renderInto(
|
||||
root,
|
||||
{
|
||||
paragraphs: [
|
||||
{ nodes: [{ type: 'text', text: 'a' }], list: 'bullet' },
|
||||
{ nodes: [{ type: 'text', text: 'b' }], list: 'bullet' },
|
||||
{ nodes: [{ type: 'text', text: 'c' }], list: 'number' },
|
||||
],
|
||||
},
|
||||
labelFor,
|
||||
);
|
||||
expect(root.querySelectorAll('ul').length).toBe(1);
|
||||
expect(root.querySelectorAll('ul > li').length).toBe(2);
|
||||
expect(root.querySelectorAll('ol > li').length).toBe(1);
|
||||
});
|
||||
|
||||
it('adjacentChip finds a chip next to a collapsed caret so Backspace/Delete can remove it', () => {
|
||||
// <p>aa{chip}bb</p>
|
||||
const p = document.createElement('p');
|
||||
const before = document.createTextNode('aa');
|
||||
const chip = document.createElement('span');
|
||||
chip.dataset['phKey'] = 'naam';
|
||||
const after = document.createTextNode('bb');
|
||||
p.append(before, chip, after);
|
||||
|
||||
// Backspace with caret just after the chip (start of "bb") → the chip.
|
||||
expect(adjacentChip(after, 0, -1)).toBe(chip);
|
||||
// Delete with caret just before the chip (end of "aa") → the chip.
|
||||
expect(adjacentChip(before, before.length, 1)).toBe(chip);
|
||||
// Element-level caret between chip and "bb" (offset 2), Backspace → the chip.
|
||||
expect(adjacentChip(p, 2, -1)).toBe(chip);
|
||||
// Caret mid-text → nothing to remove.
|
||||
expect(adjacentChip(after, 1, -1)).toBeNull();
|
||||
// At the text edge but the sibling is not a chip → null.
|
||||
expect(adjacentChip(before, 0, -1)).toBeNull();
|
||||
});
|
||||
|
||||
it('marks auto-resolvable vs manual chips with data-auto for styling', () => {
|
||||
const root = document.createElement('div');
|
||||
renderInto(
|
||||
root,
|
||||
{
|
||||
paragraphs: [
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'placeholder', key: 'naam' },
|
||||
{ type: 'placeholder', key: 'reden' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
labelFor,
|
||||
(key) => key === 'naam',
|
||||
);
|
||||
const chips = root.querySelectorAll('.rte-chip');
|
||||
expect((chips[0] as HTMLElement).dataset['auto']).toBe('true');
|
||||
expect((chips[1] as HTMLElement).dataset['auto']).toBe('false');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
|
||||
|
||||
/**
|
||||
* The quarantined boundary between the imperative `contenteditable` DOM and the
|
||||
* serialisable `RichTextBlock` value. Pure functions (given a DOM they render /
|
||||
* read) so they round-trip losslessly and can be unit-tested without Angular. The
|
||||
* rest of the app only ever sees `RichTextBlock` — this is the one place DOM leaks.
|
||||
*
|
||||
* ponytail: mark detection covers the tags/styles a browser's execCommand emits
|
||||
* (strong/b, em/i, u, and inline font-weight/style/decoration); exotic pasted markup
|
||||
* degrades to plain text rather than crashing.
|
||||
*/
|
||||
|
||||
const ORDER: readonly Mark[] = ['bold', 'italic', 'underline'];
|
||||
const MARK_TAG: Record<Mark, string> = { bold: 'strong', italic: 'em', underline: 'u' };
|
||||
|
||||
export function renderInto(
|
||||
root: HTMLElement,
|
||||
block: RichTextBlock,
|
||||
labelFor: (key: string) => string,
|
||||
autoFor?: (key: string) => boolean,
|
||||
): void {
|
||||
const doc = root.ownerDocument;
|
||||
root.replaceChildren();
|
||||
const paras = block.paragraphs;
|
||||
let i = 0;
|
||||
while (i < paras.length) {
|
||||
const para = paras[i];
|
||||
if (para.list) {
|
||||
// Group consecutive lines of the same list kind into one <ul>/<ol>.
|
||||
const listEl = doc.createElement(para.list === 'bullet' ? 'ul' : 'ol');
|
||||
listEl.className = 'rte-list';
|
||||
while (i < paras.length && paras[i].list === para.list) {
|
||||
const li = doc.createElement('li');
|
||||
fillLine(li, paras[i], labelFor, doc, autoFor);
|
||||
listEl.appendChild(li);
|
||||
i++;
|
||||
}
|
||||
root.appendChild(listEl);
|
||||
} else {
|
||||
const p = doc.createElement('p');
|
||||
p.className = 'rte-para';
|
||||
fillLine(p, para, labelFor, doc, autoFor);
|
||||
root.appendChild(p);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fill one line element (<p> or <li>) with a paragraph's nodes; empty lines keep a
|
||||
focusable <br>. Shared by paragraph and list-item rendering. */
|
||||
function fillLine(
|
||||
el: HTMLElement,
|
||||
para: Paragraph,
|
||||
labelFor: (key: string) => string,
|
||||
doc: Document,
|
||||
autoFor?: (key: string) => boolean,
|
||||
): void {
|
||||
if (para.nodes.length === 0) {
|
||||
el.appendChild(doc.createElement('br'));
|
||||
} else {
|
||||
for (const node of para.nodes) el.appendChild(renderNode(node, labelFor, doc, autoFor));
|
||||
}
|
||||
}
|
||||
|
||||
/** Build one non-editable placeholder chip element (shared by initial render and
|
||||
live caret insertion). `data-auto` distinguishes auto-resolvable vs manual fields
|
||||
for styling; it is a render hint only and is ignored on read-back. */
|
||||
export function createChip(doc: Document, key: string, label: string, auto = false): HTMLElement {
|
||||
const span = doc.createElement('span');
|
||||
span.dataset['phKey'] = key;
|
||||
span.dataset['auto'] = String(auto);
|
||||
span.setAttribute('contenteditable', 'false');
|
||||
span.className = 'rte-chip';
|
||||
span.textContent = label;
|
||||
return span;
|
||||
}
|
||||
|
||||
function renderNode(
|
||||
node: RichTextNode,
|
||||
labelFor: (key: string) => string,
|
||||
doc: Document,
|
||||
autoFor?: (key: string) => boolean,
|
||||
): Node {
|
||||
if (node.type === 'lineBreak') return doc.createElement('br');
|
||||
if (node.type === 'placeholder')
|
||||
return createChip(doc, node.key, labelFor(node.key), autoFor?.(node.key) ?? false);
|
||||
let el: Node = doc.createTextNode(node.text);
|
||||
// Nest marks in a canonical order so read-back is deterministic.
|
||||
for (const m of ORDER.filter((x) => node.marks?.includes(x))) {
|
||||
const wrap = doc.createElement(MARK_TAG[m]);
|
||||
wrap.appendChild(el);
|
||||
el = wrap;
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
export function readBlock(root: HTMLElement): RichTextBlock {
|
||||
const paragraphs: Paragraph[] = [];
|
||||
const blockEls = Array.from(root.children).filter(
|
||||
(c) => c.tagName === 'P' || c.tagName === 'DIV' || c.tagName === 'UL' || c.tagName === 'OL',
|
||||
);
|
||||
const containers = blockEls.length ? blockEls : [root];
|
||||
for (const el of containers) {
|
||||
// ponytail: only top-level lists are read as lists; a nested <ul> inside an <li>
|
||||
// flattens into its item's text (fine for this POC's flat lists).
|
||||
if (el.tagName === 'UL' || el.tagName === 'OL') {
|
||||
const list = el.tagName === 'UL' ? 'bullet' : 'number';
|
||||
for (const li of Array.from(el.children).filter((c) => c.tagName === 'LI')) {
|
||||
paragraphs.push({ ...readLine(li as HTMLElement), list });
|
||||
}
|
||||
} else {
|
||||
paragraphs.push(readLine(el as HTMLElement));
|
||||
}
|
||||
}
|
||||
return { paragraphs: paragraphs.length ? paragraphs : [{ nodes: [] }] };
|
||||
}
|
||||
|
||||
/** Read one line element (<p>, <div>, or <li>) into a paragraph's nodes. */
|
||||
function readLine(el: HTMLElement): { nodes: RichTextNode[] } {
|
||||
const kids = Array.from(el.childNodes);
|
||||
const nodes: RichTextNode[] = [];
|
||||
// A lone <br> is the empty-line filler, not a content line break.
|
||||
if (!(kids.length === 1 && kids[0].nodeName === 'BR')) {
|
||||
for (const child of kids) collect(child, [], nodes);
|
||||
}
|
||||
return { nodes };
|
||||
}
|
||||
|
||||
function collect(node: Node, marks: Mark[], out: RichTextNode[]): void {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.textContent ?? '';
|
||||
if (text !== '')
|
||||
out.push(
|
||||
marks.length ? { type: 'text', text, marks: canonical(marks) } : { type: 'text', text },
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
const el = node as HTMLElement;
|
||||
if (el.tagName === 'BR') {
|
||||
out.push({ type: 'lineBreak' });
|
||||
return;
|
||||
}
|
||||
const key = el.dataset?.['phKey'];
|
||||
if (key != null) {
|
||||
out.push({ type: 'placeholder', key });
|
||||
return;
|
||||
}
|
||||
const m = markOf(el);
|
||||
const next = m ? [...marks, m] : marks;
|
||||
for (const child of Array.from(el.childNodes)) collect(child, next, out);
|
||||
}
|
||||
|
||||
function isChip(node: Node | null | undefined): node is HTMLElement {
|
||||
return (
|
||||
!!node &&
|
||||
node.nodeType === Node.ELEMENT_NODE &&
|
||||
(node as HTMLElement).dataset?.['phKey'] != null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The placeholder chip immediately adjacent to a collapsed caret in the given direction
|
||||
* (-1 = before / Backspace, +1 = after / Delete), or null. Chips are contenteditable=false,
|
||||
* which some browsers won't delete on Backspace; the editor uses this to remove them itself.
|
||||
*/
|
||||
export function adjacentChip(
|
||||
container: Node,
|
||||
offset: number,
|
||||
direction: -1 | 1,
|
||||
): HTMLElement | null {
|
||||
let sibling: Node | null | undefined;
|
||||
if (container.nodeType === Node.TEXT_NODE) {
|
||||
// Only adjacent when the caret sits at the text edge (otherwise there are chars to delete first).
|
||||
if (direction === -1) {
|
||||
if (offset > 0) return null;
|
||||
sibling = container.previousSibling;
|
||||
} else {
|
||||
if (offset < (container.textContent?.length ?? 0)) return null;
|
||||
sibling = container.nextSibling;
|
||||
}
|
||||
} else {
|
||||
sibling = direction === -1 ? container.childNodes[offset - 1] : container.childNodes[offset];
|
||||
}
|
||||
return isChip(sibling) ? sibling : null;
|
||||
}
|
||||
|
||||
function markOf(el: HTMLElement): Mark | null {
|
||||
switch (el.tagName) {
|
||||
case 'STRONG':
|
||||
case 'B':
|
||||
return 'bold';
|
||||
case 'EM':
|
||||
case 'I':
|
||||
return 'italic';
|
||||
case 'U':
|
||||
return 'underline';
|
||||
}
|
||||
const s = el.style;
|
||||
if (s.fontWeight === 'bold' || Number(s.fontWeight) >= 600) return 'bold';
|
||||
if (s.fontStyle === 'italic') return 'italic';
|
||||
if (s.textDecoration.includes('underline')) return 'underline';
|
||||
return null;
|
||||
}
|
||||
|
||||
function canonical(marks: Mark[]): Mark[] {
|
||||
return ORDER.filter((m) => marks.includes(m));
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import { Component, ElementRef, computed, effect, input, output, viewChild } from '@angular/core';
|
||||
import { RichTextBlock, emptyBlock } from '@shared/kernel/rich-text';
|
||||
import { adjacentChip, createChip, readBlock, renderInto } from './rich-text-dom';
|
||||
|
||||
/** A menu entry for the insert-placeholder control — a plain {key,label}, so the
|
||||
editor stays domain-free (it never sees the brief's PlaceholderDef). */
|
||||
export interface PlaceholderOption {
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
// Auto-resolvable fields are filled server-side at send; manual fields need a value.
|
||||
// Drives the chip's styling so the two read apart at a glance.
|
||||
readonly autoResolvable?: boolean;
|
||||
}
|
||||
|
||||
// CIBG-GAP EXTENSION: Tekstgebied — CIBG has no rich-text/WYSIWYG pattern (a
|
||||
// contenteditable editor with formatting + placeholder chips); hand-rolled
|
||||
// surface (toolbar + chip styling), see cibg-gaps.mdx. Buttons still use the
|
||||
// vendored .btn-ghost class (WP-10).
|
||||
/**
|
||||
* Molecule: a minimal no-dependency WYSIWYG editor over a `RichTextBlock`.
|
||||
*
|
||||
* It is the single quarantined boundary to the imperative `contenteditable` DOM:
|
||||
* `content` in, `contentChanged` (a `RichTextBlock`) out, holding NO letter state.
|
||||
* Placeholders render as non-editable chips and can only be inserted from the menu
|
||||
* (valid keys only) — never typed as raw braces. Swapping in a real editor library
|
||||
* later (TipTap) means replacing only this component; nothing else sees the DOM.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-rich-text-editor',
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.rte-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--rhc-space-max-sm);
|
||||
align-items: center;
|
||||
margin-block-end: var(--rhc-space-max-sm);
|
||||
}
|
||||
.rte-toolbar button {
|
||||
min-inline-size: 2.2rem;
|
||||
}
|
||||
.rte-sep {
|
||||
inline-size: 1px;
|
||||
align-self: stretch;
|
||||
background: var(--rhc-color-border-default);
|
||||
}
|
||||
.rte-editable {
|
||||
border: 1px solid var(--rhc-color-border-default);
|
||||
border-radius: var(--rhc-border-radius-sm);
|
||||
padding: var(--rhc-space-max-md);
|
||||
min-block-size: 4rem;
|
||||
}
|
||||
.rte-editable[contenteditable='false'] {
|
||||
background: var(--rhc-color-cool-grey-100);
|
||||
}
|
||||
.rte-editable :is(p) {
|
||||
margin: 0 0 var(--rhc-space-max-sm);
|
||||
}
|
||||
.rte-editable :is(ul, ol) {
|
||||
margin: 0 0 var(--rhc-space-max-sm);
|
||||
padding-inline-start: 1.4em;
|
||||
}
|
||||
/* Placeholder chips read as fill-in fields: auto-resolvable (grey, filled server-side)
|
||||
vs manual (yellow, still needs a value). The read-only preview adds error/warning states.
|
||||
Chips are created imperatively (createChip) inside contenteditable, so they never receive
|
||||
Angular's _ngcontent scoping attribute — ::ng-deep is required or the rules won't match them.
|
||||
Braces use unicode escapes; a literal { in a CSS content string breaks the style parser. */
|
||||
:host ::ng-deep .rte-chip {
|
||||
border: 1px dashed var(--rhc-color-border-default);
|
||||
border-radius: var(--rhc-border-radius-sm);
|
||||
padding: 0 0.3em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
:host ::ng-deep .rte-chip[data-auto='true'] {
|
||||
background: var(--rhc-color-cool-grey-100);
|
||||
}
|
||||
:host ::ng-deep .rte-chip[data-auto='false'] {
|
||||
background: var(--rhc-color-geel-100);
|
||||
}
|
||||
:host ::ng-deep .rte-chip::before {
|
||||
content: '\\7B';
|
||||
opacity: 0.6;
|
||||
font-weight: 700;
|
||||
margin-inline-end: 0.1em;
|
||||
}
|
||||
:host ::ng-deep .rte-chip::after {
|
||||
content: '\\7D';
|
||||
opacity: 0.6;
|
||||
font-weight: 700;
|
||||
margin-inline-start: 0.1em;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
@if (editable()) {
|
||||
<div class="rte-toolbar" role="toolbar" [attr.aria-label]="toolbarLabel()">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost"
|
||||
(mousedown)="$event.preventDefault()"
|
||||
(click)="format('bold')"
|
||||
[attr.aria-label]="boldLabel()"
|
||||
>
|
||||
<b>B</b>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost"
|
||||
(mousedown)="$event.preventDefault()"
|
||||
(click)="format('italic')"
|
||||
[attr.aria-label]="italicLabel()"
|
||||
>
|
||||
<i>I</i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost"
|
||||
(mousedown)="$event.preventDefault()"
|
||||
(click)="format('underline')"
|
||||
[attr.aria-label]="underlineLabel()"
|
||||
>
|
||||
<u>U</u>
|
||||
</button>
|
||||
<span class="rte-sep" aria-hidden="true"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost"
|
||||
(mousedown)="$event.preventDefault()"
|
||||
(click)="list('bullet')"
|
||||
[attr.aria-label]="bulletListLabel()"
|
||||
>
|
||||
•
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost"
|
||||
(mousedown)="$event.preventDefault()"
|
||||
(click)="list('number')"
|
||||
[attr.aria-label]="numberListLabel()"
|
||||
>
|
||||
1.
|
||||
</button>
|
||||
@if (placeholders().length) {
|
||||
<span class="rte-sep" aria-hidden="true"></span>
|
||||
<label>
|
||||
<span class="app-text-subtle">{{ insertLabel() }}</span>
|
||||
<select #ins (change)="insert(ins.value); ins.value = ''">
|
||||
<option value="" selected>{{ insertPrompt() }}</option>
|
||||
@for (p of placeholders(); track p.key) {
|
||||
<option [value]="p.key">{{ p.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div
|
||||
#editor
|
||||
class="rte-editable"
|
||||
[attr.contenteditable]="editable()"
|
||||
[attr.tabindex]="editable() ? '0' : null"
|
||||
(input)="emit()"
|
||||
(keydown)="onKeydown($event)"
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
[attr.aria-label]="fieldLabel()"
|
||||
></div>
|
||||
`,
|
||||
})
|
||||
export class RichTextEditorComponent {
|
||||
content = input<RichTextBlock>(emptyBlock());
|
||||
placeholders = input<readonly PlaceholderOption[]>([]);
|
||||
editable = input(true);
|
||||
contentChanged = output<RichTextBlock>();
|
||||
|
||||
// Localizable-by-default copy (shared-UI convention).
|
||||
fieldLabel = input($localize`:@@richTextEditor.field:Tekst`);
|
||||
toolbarLabel = input($localize`:@@richTextEditor.toolbar:Opmaak`);
|
||||
boldLabel = input($localize`:@@richTextEditor.bold:Vet`);
|
||||
italicLabel = input($localize`:@@richTextEditor.italic:Cursief`);
|
||||
underlineLabel = input($localize`:@@richTextEditor.underline:Onderstreept`);
|
||||
insertLabel = input($localize`:@@richTextEditor.insert:Veld invoegen:`);
|
||||
insertPrompt = input($localize`:@@richTextEditor.insertPrompt:Kies…`);
|
||||
bulletListLabel = input($localize`:@@richTextEditor.bulletList:Opsomming`);
|
||||
numberListLabel = input($localize`:@@richTextEditor.numberList:Genummerde lijst`);
|
||||
|
||||
private editorEl = viewChild<ElementRef<HTMLElement>>('editor');
|
||||
private lastEmitted = '';
|
||||
|
||||
private labelFor = (key: string) => this.placeholders().find((p) => p.key === key)?.label ?? key;
|
||||
private autoFor = (key: string) =>
|
||||
this.placeholders().find((p) => p.key === key)?.autoResolvable ?? false;
|
||||
|
||||
constructor() {
|
||||
// Render when content arrives/changes from OUTSIDE. Skip our own emitted value
|
||||
// flowing back (structural compare) so the caret isn't reset while typing.
|
||||
effect(() => {
|
||||
const content = this.content();
|
||||
const el = this.editorEl()?.nativeElement;
|
||||
if (!el) return;
|
||||
const serialized = JSON.stringify(content);
|
||||
if (serialized === this.lastEmitted) return;
|
||||
renderInto(el, content, this.labelFor, this.autoFor);
|
||||
this.lastEmitted = serialized;
|
||||
});
|
||||
}
|
||||
|
||||
protected emit() {
|
||||
const el = this.editorEl()?.nativeElement;
|
||||
if (!el) return;
|
||||
const block = readBlock(el);
|
||||
this.lastEmitted = JSON.stringify(block);
|
||||
this.contentChanged.emit(block);
|
||||
}
|
||||
|
||||
protected format(cmd: 'bold' | 'italic' | 'underline') {
|
||||
const el = this.editorEl()?.nativeElement;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
// ponytail: execCommand is deprecated but universally supported and zero-dependency;
|
||||
// if a browser drops it, this component is the one place to swap in a range-based impl.
|
||||
el.ownerDocument.execCommand(cmd);
|
||||
this.emit();
|
||||
}
|
||||
|
||||
/** Bullet / numbered lists via execCommand — same deprecated-but-universal path as
|
||||
bold/italic (ponytail-noted on `format`); readBlock reads the resulting <ul>/<ol>. */
|
||||
protected list(kind: 'bullet' | 'number') {
|
||||
const el = this.editorEl()?.nativeElement;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
el.ownerDocument.execCommand(kind === 'bullet' ? 'insertUnorderedList' : 'insertOrderedList');
|
||||
this.emit();
|
||||
}
|
||||
|
||||
/** Ctrl/Cmd+B/I/U → our format() (+ preventDefault) so serialization runs and behaviour
|
||||
is consistent across browsers rather than relying on the native handler. */
|
||||
protected onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
this.deleteAdjacentChip(e);
|
||||
return;
|
||||
}
|
||||
if (!(e.ctrlKey || e.metaKey) || e.altKey) return;
|
||||
const cmd = { b: 'bold', i: 'italic', u: 'underline' }[e.key.toLowerCase()] as
|
||||
'bold' | 'italic' | 'underline' | undefined;
|
||||
if (!cmd) return;
|
||||
e.preventDefault();
|
||||
this.format(cmd);
|
||||
}
|
||||
|
||||
/** Backspace/Delete next to a contenteditable=false chip removes it ourselves —
|
||||
browsers otherwise leave these atomic chips undeletable. Selections fall through. */
|
||||
private deleteAdjacentChip(e: KeyboardEvent) {
|
||||
const el = this.editorEl()?.nativeElement;
|
||||
if (!el) return;
|
||||
const sel = el.ownerDocument.getSelection();
|
||||
if (!sel || !sel.isCollapsed || !sel.rangeCount) return;
|
||||
const range = sel.getRangeAt(0);
|
||||
if (!el.contains(range.startContainer)) return;
|
||||
const chip = adjacentChip(
|
||||
range.startContainer,
|
||||
range.startOffset,
|
||||
e.key === 'Backspace' ? -1 : 1,
|
||||
);
|
||||
if (!chip) return;
|
||||
e.preventDefault();
|
||||
chip.remove();
|
||||
this.emit();
|
||||
}
|
||||
|
||||
protected insert(key: string) {
|
||||
const el = this.editorEl()?.nativeElement;
|
||||
if (!key || !el) return;
|
||||
el.focus();
|
||||
const chip = createChip(el.ownerDocument, key, this.labelFor(key), this.autoFor(key));
|
||||
const sel = el.ownerDocument.getSelection();
|
||||
if (sel && sel.rangeCount && el.contains(sel.anchorNode)) {
|
||||
const range = sel.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
range.insertNode(chip);
|
||||
range.setStartAfter(chip);
|
||||
range.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
} else {
|
||||
(el.lastElementChild ?? el).appendChild(chip);
|
||||
}
|
||||
this.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { RichTextEditorComponent } from './rich-text-editor.component';
|
||||
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||
|
||||
const sample: RichTextBlock = {
|
||||
paragraphs: [
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Geachte heer/mevrouw ' },
|
||||
{ type: 'placeholder', key: 'naam_zorgverlener' },
|
||||
{ type: 'text', text: ',' },
|
||||
],
|
||||
},
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Op ' },
|
||||
{ type: 'placeholder', key: 'datum' },
|
||||
{ type: 'text', text: ' hebben wij besloten.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const placeholders = [
|
||||
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener' },
|
||||
{ key: 'datum', label: 'Datum' },
|
||||
{ key: 'big_nummer', label: 'BIG-nummer' },
|
||||
];
|
||||
|
||||
const meta: Meta<RichTextEditorComponent> = {
|
||||
title: 'Design System/Molecules/Rich Text Editor',
|
||||
component: RichTextEditorComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-rich-text-editor [content]="content" [placeholders]="placeholders" [editable]="editable"></app-rich-text-editor>`,
|
||||
}),
|
||||
parameters: {
|
||||
cibgGap: true,
|
||||
docs: { description: { component: 'CIBG-gap extension — see Foundations/CIBG Gap Register.' } },
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<RichTextEditorComponent>;
|
||||
|
||||
export const Editing: Story = { args: { content: sample, placeholders, editable: true } };
|
||||
export const Empty: Story = {
|
||||
args: { content: { paragraphs: [{ nodes: [] }] }, placeholders, editable: true },
|
||||
};
|
||||
export const ReadOnly: Story = { args: { content: sample, placeholders, editable: false } };
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Component, OnDestroy, OnInit, computed, input, signal } from '@angular/core';
|
||||
|
||||
// CIBG-GAP EXTENSION: Laadindicatie — no vendored loading-skeleton class exists
|
||||
// (verified absent from huisstijl.min.css); hand-rolled shimmer, see cibg-gaps.mdx.
|
||||
/** 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,
|
||||
var(--rhc-color-cool-grey-200) 25%,
|
||||
var(--rhc-color-cool-grey-100) 37%,
|
||||
var(--rhc-color-cool-grey-200) 63%
|
||||
);
|
||||
background-size: 400% 100%;
|
||||
animation: sh 1.4s ease infinite;
|
||||
border-radius: var(--rhc-border-radius-md);
|
||||
margin-block-end: var(--rhc-space-max-lg);
|
||||
}
|
||||
@keyframes sh {
|
||||
0% {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 0 0;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
@if (visible()) {
|
||||
@for (l of lines(); track $index) {
|
||||
<div class="sk" aria-hidden="true" [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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { SkeletonComponent } from './skeleton.component';
|
||||
|
||||
const meta: Meta<SkeletonComponent> = {
|
||||
title: 'Design System/Atoms/Skeleton',
|
||||
component: SkeletonComponent,
|
||||
args: { delay: 0 },
|
||||
parameters: {
|
||||
cibgGap: true,
|
||||
docs: { description: { component: 'CIBG-gap extension — see Foundations/CIBG Gap Register.' } },
|
||||
},
|
||||
};
|
||||
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 } };
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Component, OnDestroy, OnInit, input, signal } from '@angular/core';
|
||||
|
||||
// CIBG-GAP EXTENSION: Laadindicatie — no vendored loading-spinner class exists
|
||||
// (verified absent from huisstijl.min.css); hand-rolled, see cibg-gaps.mdx.
|
||||
/** 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 {
|
||||
inline-size: var(--rhc-space-max-3xl);
|
||||
block-size: var(--rhc-space-max-3xl);
|
||||
border-radius: var(--rhc-border-radius-round);
|
||||
border: var(--rhc-space-max-xs) solid var(--rhc-color-cool-grey-300);
|
||||
border-block-start-color: var(--rhc-color-lintblauw-700);
|
||||
animation: sp 0.8s linear infinite;
|
||||
margin: var(--rhc-space-max-2xl) auto;
|
||||
}
|
||||
@keyframes sp {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
@if (visible()) {
|
||||
<div
|
||||
class="sp"
|
||||
role="status"
|
||||
i18n-aria-label="@@spinner.aria"
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { SpinnerComponent } from './spinner.component';
|
||||
|
||||
const meta: Meta<SpinnerComponent> = {
|
||||
title: 'Design System/Atoms/Spinner',
|
||||
component: SpinnerComponent,
|
||||
parameters: {
|
||||
cibgGap: true,
|
||||
docs: { description: { component: 'CIBG-gap extension — see Foundations/CIBG Gap Register.' } },
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<SpinnerComponent>;
|
||||
|
||||
// delay 0 so it shows immediately in the story
|
||||
export const Default: Story = { args: { delay: 0 } };
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
|
||||
// CIBG-GAP EXTENSION: n/a — deliberate custom surface, not Bootstrap's `.badge`
|
||||
// (whose pill padding/colour don't fit a status dot); see cibg-gaps.mdx.
|
||||
/** 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',
|
||||
// Local class is NOT Bootstrap's .badge (which would add pill padding/colour) — hence .status-badge.
|
||||
styles: [
|
||||
`
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--rhc-space-max-md);
|
||||
}
|
||||
.dot {
|
||||
inline-size: 0.75rem;
|
||||
block-size: 0.75rem;
|
||||
border-radius: var(--rhc-border-radius-round);
|
||||
flex: none;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<span class="status-badge">
|
||||
<span class="dot" [style.background-color]="color()" aria-hidden="true"></span>
|
||||
<span>{{ label() }}</span>
|
||||
</span>
|
||||
`,
|
||||
})
|
||||
export class StatusBadgeComponent {
|
||||
label = input.required<string>();
|
||||
color = input.required<string>();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { StatusBadgeComponent } from './status-badge.component';
|
||||
|
||||
const meta: Meta<StatusBadgeComponent> = {
|
||||
title: 'Design System/Atoms/Status Badge',
|
||||
component: StatusBadgeComponent,
|
||||
parameters: {
|
||||
cibgGap: true,
|
||||
docs: { description: { component: 'CIBG-gap extension — see Foundations/CIBG Gap Register.' } },
|
||||
},
|
||||
};
|
||||
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)' },
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Component, ElementRef, input, output, viewChild } from '@angular/core';
|
||||
|
||||
/** Molecule: the CIBG Huisstijl "stappenindicator" — numbered circles (`.step-list`),
|
||||
visited steps clickable for back-only navigation, and the step title merged into
|
||||
the same block (process name + "Stap X van Y: Titel", per the aanvraagproces
|
||||
pattern). Domain-free; forward navigation only via the wizard's own buttons. */
|
||||
@Component({
|
||||
selector: 'app-stepper',
|
||||
template: `
|
||||
<div class="stepper">
|
||||
<ol class="step-list order-1">
|
||||
@for (label of steps(); track label; let i = $index) {
|
||||
<li>
|
||||
@if (i < current()) {
|
||||
<a
|
||||
href="#"
|
||||
class="step visited"
|
||||
(click)="select($event, i)"
|
||||
[attr.aria-label]="terugNaar(i, label)"
|
||||
>
|
||||
<span class="visually-hidden" i18n="@@stepper.stap">Stap</span> {{ i + 1 }}
|
||||
<span class="visually-hidden" i18n="@@stepper.voltooid">Voltooid</span>
|
||||
</a>
|
||||
} @else if (i === current()) {
|
||||
<span
|
||||
class="step active"
|
||||
aria-current="step"
|
||||
[attr.aria-label]="huidigeStap(i, label)"
|
||||
>
|
||||
<span class="visually-hidden" i18n="@@stepper.stap">Stap</span> {{ i + 1 }}
|
||||
<span class="visually-hidden" i18n="@@stepper.huidig">Huidige stap</span>
|
||||
</span>
|
||||
} @else {
|
||||
<span class="step" [attr.aria-label]="stap(i, label)">
|
||||
<span class="visually-hidden" i18n="@@stepper.stap">Stap</span> {{ i + 1 }}
|
||||
</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
<h2 #title tabindex="-1" class="h1 order-0">
|
||||
@if (processName()) {
|
||||
<span class="process-name">{{ processName() }}</span>
|
||||
}
|
||||
<ng-container i18n="@@stepper.stapVan"
|
||||
>Stap {{ current() + 1 }}<span class="visually-hidden"> van {{ steps().length }}</span
|
||||
>: {{ stepTitle() || steps()[current()] }}</ng-container
|
||||
>
|
||||
</h2>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class StepperComponent {
|
||||
steps = input.required<string[]>();
|
||||
current = input.required<number>();
|
||||
/** Name of the overall process, shown above the step title (e.g. "Herregistratie aanvragen"). */
|
||||
processName = input('');
|
||||
/** Overrides the step label as the title; falls back to `steps()[current()]`. */
|
||||
stepTitle = input('');
|
||||
|
||||
/** Emitted when a visited (earlier) step is clicked — back-navigation only. */
|
||||
stepSelected = output<number>();
|
||||
|
||||
private titleEl = viewChild<ElementRef<HTMLElement>>('title');
|
||||
|
||||
/** wizard-shell calls this after a step change so the new title is announced. */
|
||||
focusTitle(): void {
|
||||
this.titleEl()?.nativeElement.focus();
|
||||
}
|
||||
|
||||
protected select(ev: Event, i: number) {
|
||||
ev.preventDefault(); // fragment href resolves against <base href>, not the route
|
||||
this.stepSelected.emit(i);
|
||||
}
|
||||
|
||||
protected stap(i: number, label: string) {
|
||||
return $localize`:@@stepper.naarStap:Stap ${i + 1}:nummer: ${label}:label:`;
|
||||
}
|
||||
protected huidigeStap(i: number, label: string) {
|
||||
return $localize`:@@stepper.huidigeStapLabel:Huidige stap, stap ${i + 1}:nummer: ${label}:label:`;
|
||||
}
|
||||
protected terugNaar(i: number, label: string) {
|
||||
return $localize`:@@stepper.terugNaarStap:Terug naar stap ${i + 1}:nummer: ${label}:label:`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { StepperComponent } from './stepper.component';
|
||||
|
||||
const meta: Meta<StepperComponent> = {
|
||||
title: 'Design System/Molecules/Stepper',
|
||||
component: StepperComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-stepper [steps]="steps" [current]="current" [processName]="processName" [stepTitle]="stepTitle" (stepSelected)="stepSelected($event)" />`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<StepperComponent>;
|
||||
|
||||
const steps = ['Adres', 'Beroep', 'Controle'];
|
||||
const base = {
|
||||
steps,
|
||||
processName: 'Inschrijven in het BIG-register',
|
||||
stepTitle: '',
|
||||
stepSelected: () => {},
|
||||
};
|
||||
|
||||
export const Eerste: Story = { args: { ...base, current: 0 } };
|
||||
export const Midden: Story = {
|
||||
args: { ...base, current: 1, stepTitle: 'Beroep op basis van uw diploma' },
|
||||
};
|
||||
export const Laatste: Story = { args: { ...base, current: 2 } };
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { ChoiceListComponent } from '@shared/ui/choice-list/choice-list.component';
|
||||
import { ChoiceLinkComponent } from '@shared/ui/choice-link/choice-link.component';
|
||||
|
||||
/** Presentational task shape — what "Wat moet ik regelen" renders. Domain-free so
|
||||
shared/ stays independent of any context (a context's task type that has these
|
||||
fields is structurally assignable). */
|
||||
export interface TaskItem {
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
readonly to: string;
|
||||
readonly actionLabel: string;
|
||||
}
|
||||
|
||||
/** Molecule: the "Wat moet ik regelen" action list (NL Design System "Mijn
|
||||
omgeving" pattern), rendered as a CIBG Huisstijl "keuzelijst" — each task is a
|
||||
choice the user picks to resolve it. `actionLabel` has no keuzelijst equivalent
|
||||
(the whole row is the action; the chevron already implies "ga verder"). */
|
||||
@Component({
|
||||
selector: 'app-task-list',
|
||||
imports: [ChoiceListComponent, ChoiceLinkComponent],
|
||||
template: `
|
||||
<app-choice-list [heading]="listHeading()">
|
||||
@for (t of tasks(); track t.title) {
|
||||
<app-choice-link [heading]="t.title" [instructions]="t.description" [to]="t.to" />
|
||||
}
|
||||
</app-choice-list>
|
||||
`,
|
||||
})
|
||||
export class TaskListComponent {
|
||||
listHeading = input.required<string>();
|
||||
tasks = input.required<readonly TaskItem[]>();
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { applicationConfig } from '@storybook/angular';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { TaskListComponent } from './task-list.component';
|
||||
|
||||
const meta: Meta<TaskListComponent> = {
|
||||
title: 'Design System/Molecules/Task List',
|
||||
component: TaskListComponent,
|
||||
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-task-list [listHeading]="listHeading" [tasks]="tasks" />`,
|
||||
}),
|
||||
parameters: {
|
||||
// Structural: app-choice-link's host sits between the keuzelijst <ul> and its <li>
|
||||
// — axe's list/listitem rule needs them adjacent regardless of `display:contents`.
|
||||
// WP-11 (CIBG markup fidelity) reworks this markup; see docs/project/backlog/WP-11-markup-fidelity.md.
|
||||
a11y: { disable: true },
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<TaskListComponent>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
listHeading: 'Wat moet ik regelen',
|
||||
tasks: [
|
||||
{
|
||||
title: 'Vraag uw herregistratie aan',
|
||||
description: 'Verleng uw registratie vóór 31 december 2026.',
|
||||
to: '/herregistratie',
|
||||
actionLabel: 'Herregistratie aanvragen',
|
||||
},
|
||||
{
|
||||
title: 'Controleer uw adresgegevens',
|
||||
description: 'Uw adres is langer dan een jaar niet bevestigd.',
|
||||
to: '/registratie',
|
||||
actionLabel: 'Bekijk uw gegevens',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Component, booleanAttribute, 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',
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
input {
|
||||
inline-size: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<input
|
||||
class="form-control"
|
||||
[class.is-invalid]="invalid()"
|
||||
[type]="type()"
|
||||
[id]="inputId()"
|
||||
[attr.aria-invalid]="invalid() ? 'true' : null"
|
||||
[attr.aria-describedby]="describedBy()"
|
||||
[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>();
|
||||
/** Set when the paired form-field renders a `-desc` hint, so it gets announced. */
|
||||
hasDescription = input(false, { transform: booleanAttribute });
|
||||
|
||||
value = '';
|
||||
disabled = false;
|
||||
onChange: (v: string) => void = () => {};
|
||||
onTouched: () => void = () => {};
|
||||
|
||||
describedBy(): string | null {
|
||||
const id = this.inputId();
|
||||
if (!id) return null;
|
||||
const ids = [
|
||||
...(this.hasDescription() ? [`${id}-desc`] : []),
|
||||
...(this.invalid() ? [`${id}-error`] : []),
|
||||
];
|
||||
return ids.length ? ids.join(' ') : null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { TextInputComponent } from './text-input.component';
|
||||
|
||||
const meta: Meta<TextInputComponent> = {
|
||||
title: 'Design System/Atoms/Text Input',
|
||||
component: TextInputComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-text-input [type]="type" [placeholder]="placeholder" [invalid]="invalid" [inputId]="inputId" />`,
|
||||
}),
|
||||
args: { inputId: 'demo', placeholder: 'Bijv. 1234 AB' },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<TextInputComponent>;
|
||||
|
||||
export const Default: Story = {};
|
||||
export const Invalid: Story = { args: { invalid: true } };
|
||||
export const Password: Story = { args: { type: 'password', placeholder: 'Wachtwoord' } };
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import type { DeliveryChannel } from '@shared/upload/upload.machine';
|
||||
|
||||
/** Atom: choose how a document is delivered — uploaded digitally or sent by post.
|
||||
Thin wrapper over the Utrecht/RHC radio CSS. Pure UI: emits the chosen channel. */
|
||||
@Component({
|
||||
selector: 'app-delivery-channel-toggle',
|
||||
styles: [
|
||||
`
|
||||
.radio-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--rhc-space-max-md);
|
||||
padding-block: var(--rhc-space-max-sm);
|
||||
margin: 0;
|
||||
}
|
||||
.radio-option .form-check-input {
|
||||
margin: 0;
|
||||
float: none;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<div role="radiogroup">
|
||||
@for (opt of options; track opt.value) {
|
||||
<label class="form-check-label radio-option">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="radio"
|
||||
[name]="name()"
|
||||
[value]="opt.value"
|
||||
[checked]="channel() === opt.value"
|
||||
[disabled]="disabled()"
|
||||
(change)="channelChange.emit(opt.value)"
|
||||
/>
|
||||
{{ opt.label }}
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class DeliveryChannelToggleComponent {
|
||||
channel = input.required<DeliveryChannel>();
|
||||
name = input.required<string>();
|
||||
disabled = input(false);
|
||||
|
||||
channelChange = output<DeliveryChannel>();
|
||||
|
||||
protected readonly options: ReadonlyArray<{ value: DeliveryChannel; label: string }> = [
|
||||
{ value: 'digital', label: $localize`:@@upload.channel.digital:Digitaal uploaden` },
|
||||
{ value: 'post', label: $localize`:@@upload.channel.post:Per post nasturen` },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { DeliveryChannelToggleComponent } from './delivery-channel-toggle.component';
|
||||
|
||||
const meta: Meta<DeliveryChannelToggleComponent> = {
|
||||
title: 'Design System/Atoms/DeliveryChannelToggle',
|
||||
component: DeliveryChannelToggleComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-delivery-channel-toggle [channel]="channel" [name]="name" [disabled]="disabled" />`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<DeliveryChannelToggleComponent>;
|
||||
|
||||
export const Digital: Story = {
|
||||
args: { channel: 'digital', name: 'diploma-channel', disabled: false },
|
||||
};
|
||||
export const Post: Story = { args: { channel: 'post', name: 'diploma-channel', disabled: false } };
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Component, computed, input, output } from '@angular/core';
|
||||
import type { DeliveryChannel, DocumentCategory, Upload } from '@shared/upload/upload.machine';
|
||||
import { DeliveryChannelToggleComponent } from '../delivery-channel-toggle/delivery-channel-toggle.component';
|
||||
import { FileInputComponent } from '../file-input/file-input.component';
|
||||
import { SingleUploadComponent } from '../single-upload/single-upload.component';
|
||||
|
||||
/** Organism: one document category (CIBG Bestand-upload) — its label/description, an
|
||||
optional delivery channel toggle, and (when digital) a validation message, the
|
||||
file picker/drop-zone, and the `ul.file-list` of uploads. Pure UI: emits
|
||||
selection/removal/retry/delete and channel changes; no HTTP or rules. */
|
||||
@Component({
|
||||
selector: 'app-document-category',
|
||||
imports: [DeliveryChannelToggleComponent, FileInputComponent, SingleUploadComponent],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.label {
|
||||
font-weight: var(--rhc-text-font-weight-semi-bold);
|
||||
margin-block-end: var(--rhc-space-max-sm);
|
||||
}
|
||||
.req {
|
||||
font-weight: var(--rhc-text-font-weight-regular);
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
}
|
||||
.desc {
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
font-size: var(--rhc-text-font-size-sm);
|
||||
margin-block-end: var(--rhc-space-max-md);
|
||||
}
|
||||
.file-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin-block-start: var(--rhc-space-max-md);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<div class="label">
|
||||
{{ category().label }}
|
||||
@if (category().required) {
|
||||
<span class="req" i18n="@@upload.category.required">(verplicht)</span>
|
||||
}
|
||||
</div>
|
||||
@if (category().description) {
|
||||
<div class="desc">{{ category().description }}</div>
|
||||
}
|
||||
|
||||
@if (category().allowPostDelivery) {
|
||||
<app-delivery-channel-toggle
|
||||
[channel]="channel()"
|
||||
[name]="category().categoryId + '-channel'"
|
||||
(channelChange)="channelChange.emit($event)"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (channel() === 'digital') {
|
||||
<!-- CIBG: validation sits ABOVE the upload block. -->
|
||||
@if (rejection()) {
|
||||
<div class="upload-validation">
|
||||
<div class="feedback feedback-warning" role="alert">{{ rejection() }}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-file-input
|
||||
[inputId]="category().categoryId + '-file'"
|
||||
[label]="fileInputLabel()"
|
||||
[accept]="category().acceptedTypes"
|
||||
[maxSizeMb]="category().maxSizeMb"
|
||||
[multiple]="category().multiple"
|
||||
(filesSelected)="fileSelected.emit($event)"
|
||||
/>
|
||||
|
||||
@if (uploads().length) {
|
||||
<ul class="file-list">
|
||||
@for (u of uploads(); track u.localId) {
|
||||
<li
|
||||
app-single-upload
|
||||
animate.enter="app-item-enter"
|
||||
animate.leave="app-item-leave"
|
||||
[upload]="u"
|
||||
[previewUrlFor]="previewUrlFor()"
|
||||
(remove)="onRemove(u)"
|
||||
(retry)="retryUpload.emit(u.localId)"
|
||||
></li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class DocumentCategoryComponent {
|
||||
category = input.required<DocumentCategory>();
|
||||
uploads = input.required<Upload[]>();
|
||||
channel = input.required<DeliveryChannel>();
|
||||
rejection = input<string>();
|
||||
previewUrlFor = input<(documentId: string) => string | undefined>();
|
||||
|
||||
/** Accessible name for the file picker, e.g. "Bestand kiezen voor Diploma". */
|
||||
protected fileInputLabel = computed(
|
||||
() =>
|
||||
$localize`:@@upload.fileInput.labelFor:Bestand kiezen voor ${this.category().label}:category:`,
|
||||
);
|
||||
|
||||
fileSelected = output<File[]>();
|
||||
removeUpload = output<string>();
|
||||
retryUpload = output<string>();
|
||||
deleteUpload = output<{ localId: string; documentId: string }>();
|
||||
channelChange = output<DeliveryChannel>();
|
||||
|
||||
onRemove(u: Upload) {
|
||||
if (u.status.type === 'complete') {
|
||||
this.deleteUpload.emit({ localId: u.localId, documentId: u.status.documentId });
|
||||
} else {
|
||||
this.removeUpload.emit(u.localId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import type { DocumentCategory, Upload } from '@shared/upload/upload.machine';
|
||||
import { DocumentCategoryComponent } from './document-category.component';
|
||||
|
||||
const meta: Meta<DocumentCategoryComponent> = {
|
||||
title: 'Design System/Organisms/DocumentCategory',
|
||||
component: DocumentCategoryComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-document-category [category]="category" [uploads]="uploads" [channel]="channel" [rejection]="rejection" />`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<DocumentCategoryComponent>;
|
||||
|
||||
const category: DocumentCategory = {
|
||||
categoryId: 'diploma',
|
||||
label: 'Diploma',
|
||||
description: 'Een kopie van uw diploma (PDF, max 10 MB).',
|
||||
required: true,
|
||||
acceptedTypes: ['application/pdf'],
|
||||
maxSizeMb: 10,
|
||||
multiple: false,
|
||||
allowPostDelivery: true,
|
||||
};
|
||||
|
||||
const uploads: Upload[] = [
|
||||
{
|
||||
localId: 'u-1',
|
||||
categoryId: 'diploma',
|
||||
fileName: 'diploma.pdf',
|
||||
fileSizeMb: 1.2,
|
||||
status: { type: 'complete', documentId: 'doc-1' },
|
||||
backgroundSync: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const Digital: Story = {
|
||||
args: { category, uploads, channel: 'digital', rejection: undefined },
|
||||
};
|
||||
|
||||
export const WithRejection: Story = {
|
||||
args: {
|
||||
category,
|
||||
uploads: [],
|
||||
channel: 'digital',
|
||||
rejection: 'Dit bestandstype is niet toegestaan voor deze categorie.',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Component, computed, input } from '@angular/core';
|
||||
import type { UploadStatus } from '@shared/upload/upload.machine';
|
||||
import { UploadStatusIconComponent } from '../upload-status-icon/upload-status-icon.component';
|
||||
|
||||
const STATUS_LABELS: Record<UploadStatus['type'], string> = {
|
||||
idle: '',
|
||||
queued: $localize`:@@upload.status.queued:In wachtrij`,
|
||||
uploading: $localize`:@@upload.status.uploading:Bezig met uploaden`,
|
||||
complete: $localize`:@@upload.status.complete:Geüpload`,
|
||||
failed: $localize`:@@upload.status.failed:Mislukt`,
|
||||
deleting: $localize`:@@upload.status.deleting:Bezig met verwijderen`,
|
||||
deleted: '',
|
||||
};
|
||||
|
||||
/** Atom: the `.file` block of a CIBG Bestand-upload file row — a status glyph, the
|
||||
filename (a download link once complete) and a `.file-meta` line (size + status).
|
||||
`display:contents` so `.file` becomes a flex child of the `.file-container` row.
|
||||
Pure UI. */
|
||||
@Component({
|
||||
selector: 'app-document-chip',
|
||||
imports: [UploadStatusIconComponent],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
.file {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--rhc-space-max-xs);
|
||||
min-inline-size: 0;
|
||||
}
|
||||
.file-name {
|
||||
word-break: break-all;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<div class="file">
|
||||
<span class="d-inline-flex align-items-center gap-2">
|
||||
<app-upload-status-icon [status]="status().type" />
|
||||
@if (previewUrl()) {
|
||||
<a class="file-name" [href]="previewUrl()" target="_blank" rel="noopener">{{
|
||||
fileName()
|
||||
}}</a>
|
||||
} @else {
|
||||
<span class="file-name">{{ fileName() }}</span>
|
||||
}
|
||||
</span>
|
||||
<span class="file-meta">{{ meta() }}</span>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class DocumentChipComponent {
|
||||
fileName = input.required<string>();
|
||||
status = input.required<UploadStatus>();
|
||||
fileSizeMb = input(0);
|
||||
/** When set, the filename is a preview/download link (opens the stored bytes). */
|
||||
previewUrl = input<string>();
|
||||
|
||||
/** `.file-meta`: file size + status word (+ the failure reason when failed). */
|
||||
protected meta = computed(() => {
|
||||
const s = this.status();
|
||||
const size = this.fileSizeMb() > 0 ? `${this.fileSizeMb().toFixed(1)} MB` : '';
|
||||
const label = s.type === 'failed' && s.reason ? s.reason : STATUS_LABELS[s.type];
|
||||
return [size, label].filter(Boolean).join(' · ');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import type { UploadStatus } from '@shared/upload/upload.machine';
|
||||
import { DocumentChipComponent } from './document-chip.component';
|
||||
|
||||
const meta: Meta<DocumentChipComponent> = {
|
||||
title: 'Design System/Atoms/DocumentChip',
|
||||
component: DocumentChipComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
// The .file/.file-name/.file-meta styling only applies inside ul.file-list > .file-container.
|
||||
template: `<ul class="file-list"><li class="file-container"><app-document-chip [fileName]="fileName" [status]="status" [fileSizeMb]="fileSizeMb" [previewUrl]="previewUrl" /></li></ul>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<DocumentChipComponent>;
|
||||
|
||||
export const Complete: Story = {
|
||||
args: {
|
||||
fileName: 'diploma.pdf',
|
||||
fileSizeMb: 1.2,
|
||||
status: { type: 'complete', documentId: 'doc-1' } as UploadStatus,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithPreview: Story = {
|
||||
args: {
|
||||
fileName: 'diploma.pdf',
|
||||
fileSizeMb: 1.2,
|
||||
status: { type: 'complete', documentId: 'doc-1' } as UploadStatus,
|
||||
previewUrl: '/api/v1/uploads/doc-1/content',
|
||||
},
|
||||
};
|
||||
|
||||
export const Failed: Story = {
|
||||
args: {
|
||||
fileName: 'diploma.pdf',
|
||||
fileSizeMb: 1.2,
|
||||
status: { type: 'failed', reason: 'Netwerkfout' } as UploadStatus,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import type { DeliveryChannel, UploadState } from '@shared/upload/upload.machine';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { DocumentCategoryComponent } from '../document-category/document-category.component';
|
||||
|
||||
/** Organism: the full document-upload step — the category list, or a load-error
|
||||
banner. Pure UI: re-exposes the category events, tagging each with its category
|
||||
where the parent needs it. The container wires these to the upload reducer. */
|
||||
@Component({
|
||||
selector: 'app-document-upload',
|
||||
imports: [DocumentCategoryComponent, AlertComponent],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--rhc-space-max-xl);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
@if (state().categoriesError) {
|
||||
<app-alert type="error">{{ state().categoriesError }}</app-alert>
|
||||
} @else {
|
||||
@if (state().backgroundSyncAvailable === false && state().categories.length > 0) {
|
||||
<app-alert type="info">{{ foregroundOnlyMessage }}</app-alert>
|
||||
}
|
||||
@for (c of state().categories; track c.categoryId) {
|
||||
<app-document-category
|
||||
[category]="c"
|
||||
[uploads]="uploadsFor(c.categoryId)"
|
||||
[channel]="channelFor(c.categoryId)"
|
||||
[rejection]="state().rejections[c.categoryId]"
|
||||
[previewUrlFor]="previewUrlFor()"
|
||||
(fileSelected)="fileSelected.emit({ categoryId: c.categoryId, files: $event })"
|
||||
(removeUpload)="removeUpload.emit($event)"
|
||||
(retryUpload)="retryUpload.emit($event)"
|
||||
(deleteUpload)="deleteUpload.emit($event)"
|
||||
(channelChange)="channelChange.emit({ categoryId: c.categoryId, channel: $event })"
|
||||
/>
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class DocumentUploadComponent {
|
||||
state = input.required<UploadState>();
|
||||
/** Optional: builds a preview/download URL for a completed upload's documentId. */
|
||||
previewUrlFor = input<(documentId: string) => string | undefined>();
|
||||
|
||||
fileSelected = output<{ categoryId: string; files: File[] }>();
|
||||
removeUpload = output<string>();
|
||||
retryUpload = output<string>();
|
||||
deleteUpload = output<{ localId: string; documentId: string }>();
|
||||
channelChange = output<{ categoryId: string; channel: DeliveryChannel }>();
|
||||
|
||||
protected readonly foregroundOnlyMessage = $localize`:@@upload.foregroundOnly:Uploads gaan alleen door zolang deze pagina open blijft.`;
|
||||
|
||||
protected uploadsFor(categoryId: string) {
|
||||
return this.state().uploads.filter((u) => u.categoryId === categoryId);
|
||||
}
|
||||
protected channelFor(categoryId: string): DeliveryChannel {
|
||||
return this.state().deliveryChannel[categoryId] ?? 'digital';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import type { UploadState } from '@shared/upload/upload.machine';
|
||||
import { DocumentUploadComponent } from './document-upload.component';
|
||||
|
||||
const meta: Meta<DocumentUploadComponent> = {
|
||||
title: 'Design System/Organisms/DocumentUpload',
|
||||
component: DocumentUploadComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-document-upload [state]="state" />`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<DocumentUploadComponent>;
|
||||
|
||||
const state: UploadState = {
|
||||
categories: [
|
||||
{
|
||||
categoryId: 'diploma',
|
||||
label: 'Diploma',
|
||||
description: 'Een kopie van uw diploma (PDF, max 10 MB).',
|
||||
required: true,
|
||||
acceptedTypes: ['application/pdf'],
|
||||
maxSizeMb: 10,
|
||||
multiple: false,
|
||||
allowPostDelivery: true,
|
||||
},
|
||||
{
|
||||
categoryId: 'cv',
|
||||
label: 'Curriculum vitae',
|
||||
description: 'Uw cv (PDF of Word).',
|
||||
required: false,
|
||||
acceptedTypes: ['application/pdf', 'application/msword'],
|
||||
maxSizeMb: 5,
|
||||
multiple: true,
|
||||
allowPostDelivery: false,
|
||||
},
|
||||
],
|
||||
uploads: [
|
||||
{
|
||||
localId: 'u-1',
|
||||
categoryId: 'diploma',
|
||||
fileName: 'diploma.pdf',
|
||||
fileSizeMb: 1.2,
|
||||
status: { type: 'complete', documentId: 'doc-1' },
|
||||
backgroundSync: false,
|
||||
},
|
||||
],
|
||||
deliveryChannel: { diploma: 'digital', cv: 'digital' },
|
||||
rejections: {},
|
||||
backgroundSyncAvailable: true,
|
||||
};
|
||||
|
||||
export const Default: Story = { args: { state } };
|
||||
|
||||
export const LoadError: Story = {
|
||||
args: { state: { ...state, categoriesError: 'De categorieën konden niet worden geladen.' } },
|
||||
};
|
||||
|
||||
export const ForegroundOnly: Story = {
|
||||
args: { state: { ...state, backgroundSyncAvailable: false } },
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Component, ElementRef, computed, input, output, signal, viewChild } from '@angular/core';
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
/** Friendly labels for the MIME types the backend sends in `acceptedTypes`. */
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
'application/pdf': 'PDF',
|
||||
'image/jpeg': 'JPG',
|
||||
'image/jpg': 'JPG',
|
||||
'image/png': 'PNG',
|
||||
'application/msword': 'Word',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word',
|
||||
};
|
||||
|
||||
/** Atom: the CIBG Huisstijl **Bestand-upload** control
|
||||
(designsystem.cibg.nl/componenten/bestand-upload) — a `.file-picker-drop-area`
|
||||
with an always-visible instruction (allowed types + max size), a real
|
||||
`.btn-upload` button, and a visually-hidden native `<input type="file">`. Supports
|
||||
click-to-pick and drag-and-drop; resets its value after each change so re-picking
|
||||
the same file re-fires. The instruction is linked to the input via
|
||||
`aria-describedby` (pattern requirement). Pure UI — no validation, no upload. */
|
||||
@Component({
|
||||
selector: 'app-file-input',
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.file-picker-drop-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--rhc-space-max-sm);
|
||||
}
|
||||
/* Default (not subtle) foreground: subtle grey on the grey drop-area fails WCAG AA contrast. */
|
||||
.upload-instructions {
|
||||
margin: 0;
|
||||
color: var(--rhc-color-foreground-default);
|
||||
font-size: var(--rhc-text-font-size-sm);
|
||||
}
|
||||
/* Solid CIBG-blue button; the .btn-upload ::before folder glyph is the icon, so
|
||||
drop its redundant centred background-image folder. */
|
||||
.btn-upload {
|
||||
cursor: pointer;
|
||||
background-image: none;
|
||||
}
|
||||
.btn-upload.disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
/* The input is visually hidden but still focusable; surface its focus on the button. */
|
||||
input:focus-visible + .btn-upload {
|
||||
outline: var(--rhc-border-width-md) solid var(--rhc-color-foreground-link);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<div
|
||||
class="file-picker-drop-area"
|
||||
[class.drag-over]="dragging()"
|
||||
(dragover)="onDragOver($event)"
|
||||
(dragleave)="onDragLeave($event)"
|
||||
(drop)="onDrop($event)"
|
||||
>
|
||||
@if (instructions()) {
|
||||
<p class="upload-instructions" [id]="instructionsId">{{ instructions() }}</p>
|
||||
}
|
||||
<input
|
||||
#fileInput
|
||||
class="visually-hidden"
|
||||
type="file"
|
||||
[id]="inputId()"
|
||||
[attr.aria-label]="label()"
|
||||
[attr.aria-describedby]="instructions() ? instructionsId : null"
|
||||
[accept]="accept().join(',')"
|
||||
[multiple]="multiple()"
|
||||
[disabled]="disabled()"
|
||||
(change)="onChange($event)"
|
||||
/>
|
||||
<label
|
||||
class="btn btn-primary btn-upload"
|
||||
[class.disabled]="disabled()"
|
||||
[attr.for]="inputId()"
|
||||
>
|
||||
{{ buttonText() }}
|
||||
</label>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class FileInputComponent {
|
||||
accept = input<string[]>([]);
|
||||
multiple = input(false);
|
||||
disabled = input(false);
|
||||
maxSizeMb = input(0);
|
||||
inputId = input.required<string>();
|
||||
/** Accessible name for the input; the domain caller supplies a per-category label. */
|
||||
label = input($localize`:@@upload.fileInput.label:Bestand kiezen`);
|
||||
|
||||
filesSelected = output<File[]>();
|
||||
|
||||
protected readonly instructionsId = `upload-instructions-${nextId++}`;
|
||||
protected readonly dragging = signal(false);
|
||||
|
||||
private fileInput = viewChild.required<ElementRef<HTMLInputElement>>('fileInput');
|
||||
|
||||
protected buttonText = computed(() =>
|
||||
this.multiple()
|
||||
? $localize`:@@upload.fileInput.addMany:Bestanden toevoegen`
|
||||
: $localize`:@@upload.fileInput.addOne:Bestand toevoegen`,
|
||||
);
|
||||
|
||||
/** Always-visible instruction: allowed file types + max size (CIBG pattern). */
|
||||
protected instructions = computed(() => {
|
||||
const types = this.accept()
|
||||
.map((m) => TYPE_LABELS[m] ?? m.split('/').pop()?.toUpperCase() ?? m)
|
||||
.join(', ');
|
||||
const size = this.maxSizeMb();
|
||||
if (types && size > 0)
|
||||
return $localize`:@@upload.fileInput.instrBoth:Toegestaan: ${types}:types: · max ${size}:size: MB`;
|
||||
if (types) return $localize`:@@upload.fileInput.instrTypes:Toegestaan: ${types}:types:`;
|
||||
if (size > 0) return $localize`:@@upload.fileInput.instrSize:Max ${size}:size: MB`;
|
||||
return '';
|
||||
});
|
||||
|
||||
onChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.filesSelected.emit(Array.from(input.files ?? []));
|
||||
this.fileInput().nativeElement.value = '';
|
||||
}
|
||||
|
||||
onDragOver(event: DragEvent) {
|
||||
if (this.disabled()) return;
|
||||
event.preventDefault(); // allow drop
|
||||
this.dragging.set(true);
|
||||
}
|
||||
onDragLeave(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
this.dragging.set(false);
|
||||
}
|
||||
onDrop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
this.dragging.set(false);
|
||||
if (this.disabled()) return;
|
||||
const files = Array.from(event.dataTransfer?.files ?? []);
|
||||
if (files.length) this.filesSelected.emit(files);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { FileInputComponent } from './file-input.component';
|
||||
|
||||
const meta: Meta<FileInputComponent> = {
|
||||
title: 'Design System/Atoms/FileInput',
|
||||
component: FileInputComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-file-input [inputId]="inputId" [accept]="accept" [maxSizeMb]="maxSizeMb" [multiple]="multiple" [disabled]="disabled" />`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<FileInputComponent>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
inputId: 'diploma',
|
||||
accept: ['application/pdf', 'image/jpeg'],
|
||||
maxSizeMb: 10,
|
||||
multiple: false,
|
||||
disabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const Multiple: Story = {
|
||||
args: {
|
||||
inputId: 'cv',
|
||||
accept: ['application/pdf'],
|
||||
maxSizeMb: 5,
|
||||
multiple: true,
|
||||
disabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
inputId: 'diploma-disabled',
|
||||
accept: ['application/pdf'],
|
||||
maxSizeMb: 10,
|
||||
multiple: false,
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Component, computed, input, output } from '@angular/core';
|
||||
import type { Upload } from '@shared/upload/upload.machine';
|
||||
import { DocumentChipComponent } from '../document-chip/document-chip.component';
|
||||
import { UploadProgressBarComponent } from '../upload-progress-bar/upload-progress-bar.component';
|
||||
|
||||
/** Molecule: one row in a CIBG Bestand-upload file list — a `.file-container` `<li>`
|
||||
with the `.file` block (via document-chip), the `.actions` (retry/remove), and a
|
||||
progress bar while uploading. Used on an `<li>` so `ul.file-list`'s direct child is
|
||||
a native `<li>`. Pure UI: emits `remove`/`retry`; the container decides what they mean. */
|
||||
@Component({
|
||||
selector: 'li[app-single-upload]',
|
||||
host: { class: 'file-container' },
|
||||
imports: [DocumentChipComponent, UploadProgressBarComponent],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.upload-progress {
|
||||
flex: 1 0 100%;
|
||||
margin-block-start: var(--rhc-space-max-sm);
|
||||
}
|
||||
.actions .btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.actions .btn-retry {
|
||||
color: var(--rhc-color-foreground-link);
|
||||
text-decoration: underline;
|
||||
font: inherit;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<app-document-chip
|
||||
[fileName]="upload().fileName"
|
||||
[status]="upload().status"
|
||||
[fileSizeMb]="upload().fileSizeMb"
|
||||
[previewUrl]="previewUrl()"
|
||||
/>
|
||||
<div class="actions">
|
||||
@if (upload().status.type === 'failed') {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-retry"
|
||||
[attr.aria-label]="retryLabel"
|
||||
(click)="retry.emit()"
|
||||
i18n="@@upload.chip.retry"
|
||||
>
|
||||
Opnieuw
|
||||
</button>
|
||||
}
|
||||
@if (upload().status.type !== 'deleting') {
|
||||
<button
|
||||
type="button"
|
||||
class="btn icon-remove"
|
||||
[attr.aria-label]="removeLabel"
|
||||
(click)="remove.emit()"
|
||||
></button>
|
||||
}
|
||||
</div>
|
||||
@if (progressPct() !== null) {
|
||||
<app-upload-progress-bar class="upload-progress" [progressPct]="progressPct()!" />
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class SingleUploadComponent {
|
||||
upload = input.required<Upload>();
|
||||
/** Builds a preview URL for a completed upload's documentId (undefined = no link). */
|
||||
previewUrlFor = input<(documentId: string) => string | undefined>();
|
||||
|
||||
/** Narrow the status union once, in TS, so the template stays type-safe. */
|
||||
protected readonly progressPct = computed<number | null>(() => {
|
||||
const status = this.upload().status;
|
||||
return status.type === 'uploading' ? status.progressPct : null;
|
||||
});
|
||||
|
||||
protected readonly previewUrl = computed<string | undefined>(() => {
|
||||
const status = this.upload().status;
|
||||
return status.type === 'complete' ? this.previewUrlFor()?.(status.documentId) : undefined;
|
||||
});
|
||||
|
||||
remove = output<void>();
|
||||
retry = output<void>();
|
||||
|
||||
protected get removeLabel(): string {
|
||||
return $localize`:@@upload.chip.removeAria:${this.upload().fileName}:fileName: verwijderen`;
|
||||
}
|
||||
protected get retryLabel(): string {
|
||||
return $localize`:@@upload.chip.retryAria:${this.upload().fileName}:fileName: opnieuw uploaden`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import type { Upload } from '@shared/upload/upload.machine';
|
||||
import { SingleUploadComponent } from './single-upload.component';
|
||||
|
||||
const meta: Meta<SingleUploadComponent> = {
|
||||
title: 'Design System/Molecules/SingleUpload',
|
||||
component: SingleUploadComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<ul class="file-list"><li app-single-upload [upload]="upload"></li></ul>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<SingleUploadComponent>;
|
||||
|
||||
const base: Upload = {
|
||||
localId: 'u-1',
|
||||
categoryId: 'diploma',
|
||||
fileName: 'diploma.pdf',
|
||||
fileSizeMb: 1.2,
|
||||
status: { type: 'complete', documentId: 'doc-1' },
|
||||
backgroundSync: false,
|
||||
};
|
||||
|
||||
export const Complete: Story = { args: { upload: base } };
|
||||
|
||||
export const Uploading: Story = {
|
||||
args: { upload: { ...base, status: { type: 'uploading', progressPct: 60 } } },
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
|
||||
/** Atom: native progress bar for an in-flight upload. Pure UI — the caller supplies
|
||||
the percentage. */
|
||||
@Component({
|
||||
selector: 'app-upload-progress-bar',
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--rhc-space-max-md);
|
||||
}
|
||||
progress {
|
||||
flex: 1;
|
||||
height: var(--rhc-space-max-md);
|
||||
}
|
||||
.pct {
|
||||
font-size: var(--rhc-text-font-size-sm);
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
min-width: 3ch;
|
||||
text-align: end;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<progress max="100" [value]="progressPct()" [attr.aria-label]="progressLabel"></progress>
|
||||
<span class="pct">{{ progressPct() }}%</span>
|
||||
`,
|
||||
})
|
||||
export class UploadProgressBarComponent {
|
||||
progressPct = input.required<number>();
|
||||
|
||||
protected readonly progressLabel = $localize`:@@upload.progress.label:Uploadvoortgang`;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { UploadProgressBarComponent } from './upload-progress-bar.component';
|
||||
|
||||
const meta: Meta<UploadProgressBarComponent> = {
|
||||
title: 'Design System/Atoms/UploadProgressBar',
|
||||
component: UploadProgressBarComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-upload-progress-bar [progressPct]="progressPct" />`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<UploadProgressBarComponent>;
|
||||
|
||||
export const Halfway: Story = { args: { progressPct: 45 } };
|
||||
export const Almost: Story = { args: { progressPct: 92 } };
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Component, computed, input } from '@angular/core';
|
||||
import type { UploadStatus } from '@shared/upload/upload.machine';
|
||||
|
||||
interface Glyph {
|
||||
char: string;
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/** Atom: a small status glyph for one upload. Pure UI — glyph, colour and a11y
|
||||
label derive purely from the status type. */
|
||||
@Component({
|
||||
selector: 'app-upload-status-icon',
|
||||
styles: [
|
||||
`
|
||||
.glyph {
|
||||
font-weight: var(--rhc-text-font-weight-semi-bold);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
@if (glyph()) {
|
||||
<span
|
||||
class="glyph"
|
||||
[style.color]="glyph()!.color"
|
||||
[attr.aria-label]="glyph()!.label"
|
||||
role="img"
|
||||
>
|
||||
{{ glyph()!.char }}
|
||||
</span>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class UploadStatusIconComponent {
|
||||
status = input.required<UploadStatus['type']>();
|
||||
|
||||
protected readonly glyph = computed<Glyph | null>(() => {
|
||||
switch (this.status()) {
|
||||
case 'queued':
|
||||
return {
|
||||
char: '…',
|
||||
label: $localize`:@@upload.status.queued:In wachtrij`,
|
||||
color: 'var(--rhc-color-foreground-subtle)',
|
||||
};
|
||||
case 'uploading':
|
||||
return {
|
||||
char: '↑',
|
||||
label: $localize`:@@upload.status.uploading:Bezig met uploaden`,
|
||||
color: 'var(--rhc-color-lintblauw-600)',
|
||||
};
|
||||
case 'complete':
|
||||
return {
|
||||
char: '✓',
|
||||
label: $localize`:@@upload.status.complete:Geüpload`,
|
||||
color: 'var(--rhc-color-groen-500)',
|
||||
};
|
||||
case 'failed':
|
||||
return {
|
||||
char: '✕',
|
||||
label: $localize`:@@upload.status.failed:Mislukt`,
|
||||
color: 'var(--rhc-color-rood-500)',
|
||||
};
|
||||
case 'deleting':
|
||||
return {
|
||||
char: '⟳',
|
||||
label: $localize`:@@upload.status.deleting:Bezig met verwijderen`,
|
||||
color: 'var(--rhc-color-foreground-subtle)',
|
||||
};
|
||||
case 'idle':
|
||||
case 'deleted':
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { UploadStatusIconComponent } from './upload-status-icon.component';
|
||||
|
||||
const meta: Meta<UploadStatusIconComponent> = {
|
||||
title: 'Design System/Atoms/UploadStatusIcon',
|
||||
component: UploadStatusIconComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-upload-status-icon [status]="status" />`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<UploadStatusIconComponent>;
|
||||
|
||||
export const Complete: Story = { args: { status: 'complete' } };
|
||||
export const Failed: Story = { args: { status: 'failed' } };
|
||||
export const Uploading: Story = { args: { status: 'uploading' } };
|
||||
Reference in New Issue
Block a user