style: format frontend, docs and skills with prettier; add .prettierignore

One-time prettier --write so the new format:check CI gate starts green.
.prettierignore excludes generated (api-client.ts, documentation.json),
vendored (public/cibg-huisstijl), and backend (dotnet format owns it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 13:39:31 +02:00
parent 546097434d
commit e82309786d
176 changed files with 5069 additions and 1471 deletions

View File

@@ -16,7 +16,14 @@ const ICON_LABELS: Record<AlertType, string> = {
only the icon's a11y label and a content wrapper (`.feedback` is a flex row). */
@Component({
selector: 'app-alert',
styles: [`.feedback>div{flex:1 1 auto;min-width:0}`],
styles: [
`
.feedback > div {
flex: 1 1 auto;
min-width: 0;
}
`,
],
template: `
<div
class="feedback"
@@ -25,8 +32,11 @@ const ICON_LABELS: Record<AlertType, string> = {
[class.feedback-warning]="type() === 'warning'"
[class.feedback-error]="type() === 'error'"
role="status"
aria-atomic="true">
<span class="icon"><span class="visually-hidden">{{ iconLabels[type()] }}</span></span>
aria-atomic="true"
>
<span class="icon"
><span class="visually-hidden">{{ iconLabels[type()] }}</span></span
>
<div><ng-content /></div>
</div>
`,

View File

@@ -14,13 +14,26 @@ import { RouterLink } from '@angular/router';
@Component({
selector: 'li[app-application-link]',
imports: [RouterLink, NgTemplateOutlet],
styles: [`
/* The vendored .applications li a surface only styles <a>; mirror it from tokens
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:.065rem solid var(--rhc-color-border-subtle);padding:.75rem 2rem .75rem 1rem}
.content{flex:1 1 auto;min-inline-size:0}
.cta{margin-inline-start:auto;align-self:center}
`],
.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>
@@ -33,10 +46,16 @@ import { RouterLink } from '@angular/router';
<ng-template #body>
<div class="content">
<h3 class="h3">{{ heading() }}</h3>
@if (subtitle()) { <div class="subtitle">{{ subtitle() }}</div> }
@if (status()) { <div class="status">{{ status() }}</div> }
@if (subtitle()) {
<div class="subtitle">{{ subtitle() }}</div>
}
@if (status()) {
<div class="status">{{ status() }}</div>
}
</div>
@if (cta()) { <div class="cta">{{ cta() }}</div> }
@if (cta()) {
<div class="cta">{{ cta() }}</div>
}
</ng-template>
`,
})

View File

@@ -17,7 +17,11 @@ export default meta;
type Story = StoryObj<ApplicationLinkComponent>;
export const Navigatie: Story = {
args: { heading: 'Inschrijven', subtitle: 'Schrijf u in in het BIG-register.', to: '/registreren' },
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 },

View File

@@ -36,31 +36,41 @@ export class AsyncErrorDirective {
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>
@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() }"
/>
}
}
@case ('Empty') {
@if (emptyTpl()) { <ng-container [ngTemplateOutlet]="emptyTpl()!.tpl" /> }
@else { <p>{{ emptyText() }}</p> }
}
@case ('Success') {
<ng-container [ngTemplateOutlet]="loadedTpl().tpl"
[ngTemplateOutletContext]="{ $implicit: value() }" />
}
}
</div>
`,
})
@@ -94,10 +104,20 @@ export class AsyncComponent<T> {
// 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 }),
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 }),
foldRemote(this.rd(), {
loading: () => undefined,
empty: () => undefined,
failure: (e) => e,
success: () => undefined,
}),
);
retry = () => {
@@ -110,6 +130,9 @@ export class AsyncComponent<T> {
/** Convenience: import this array to get the wrapper + all slot directives. */
export const ASYNC = [
AsyncComponent, AsyncLoadedDirective, AsyncLoadingDirective,
AsyncEmptyDirective, AsyncErrorDirective,
AsyncComponent,
AsyncLoadedDirective,
AsyncLoadingDirective,
AsyncEmptyDirective,
AsyncErrorDirective,
] as const;

View File

@@ -37,7 +37,11 @@ const meta: Meta = {
export default meta;
type Story = StoryObj;
export const Loaded: Story = { args: { resource: fakeResource('resolved', ['Huisartsgeneeskunde', 'Spoedeisende hulp']) } };
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')) } };
export const ErrorState: Story = {
args: { resource: fakeResource('error', undefined, new Error('Demo')) },
};

View File

@@ -13,7 +13,8 @@ type Variant = 'primary' | 'secondary' | 'subtle' | 'danger';
[class.btn-primary]="variant() === 'primary'"
[class.btn-outline-primary]="variant() === 'secondary'"
[class.btn-link]="variant() === 'subtle'"
[class.btn-danger]="variant() === 'danger'">
[class.btn-danger]="variant() === 'danger'"
>
<ng-content />
</button>
`,

View File

@@ -8,21 +8,30 @@ import { HeadingComponent } from '@shared/ui/heading/heading.component';
@Component({
selector: 'app-card',
imports: [HeadingComponent],
styles: [`
:host{display:block;block-size:100%}
.app-card{
background:var(--rhc-color-wit);
border:var(--rhc-border-width-sm) solid var(--rhc-color-border-subtle);
border-radius:var(--rhc-border-radius-md);
padding:var(--rhc-space-max-xl);
block-size:100%;
box-sizing:border-box;
}
.app-card > * + *{margin-block-start:var(--rhc-space-max-md)}
`],
styles: [
`
:host {
display: block;
block-size: 100%;
}
.app-card {
background: var(--rhc-color-wit);
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-subtle);
border-radius: var(--rhc-border-radius-md);
padding: var(--rhc-space-max-xl);
block-size: 100%;
box-sizing: border-box;
}
.app-card > * + * {
margin-block-start: var(--rhc-space-max-md);
}
`,
],
template: `
<section class="app-card">
@if (heading()) { <app-heading [level]="level()">{{ heading() }}</app-heading> }
@if (heading()) {
<app-heading [level]="level()">{{ heading() }}</app-heading>
}
<ng-content />
</section>
`,

View File

@@ -8,12 +8,21 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
selector: 'app-checkbox',
template: `
<div class="form-check styled">
<input class="form-check-input" type="checkbox" [id]="checkboxId()" [checked]="value" [disabled]="disabled"
(change)="onToggle($event)" (blur)="onTouched()" />
<input
class="form-check-input"
type="checkbox"
[id]="checkboxId()"
[checked]="value"
[disabled]="disabled"
(change)="onToggle($event)"
(blur)="onTouched()"
/>
<label class="form-check-label" [for]="checkboxId()">{{ label() }}</label>
</div>
`,
providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => CheckboxComponent), multi: true }],
providers: [
{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => CheckboxComponent), multi: true },
],
})
export class CheckboxComponent implements ControlValueAccessor {
checkboxId = input<string>();
@@ -28,8 +37,16 @@ export class CheckboxComponent implements ControlValueAccessor {
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; }
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;
}
}

View File

@@ -21,13 +21,27 @@ import { RouterLink } from '@angular/router';
@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}
`],
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()">
@@ -40,7 +54,9 @@ import { RouterLink } from '@angular/router';
{{ heading() }}
}
</h3>
@if (instructions()) { <p class="keuzelijst__instructions">{{ instructions() }}</p> }
@if (instructions()) {
<p class="keuzelijst__instructions">{{ instructions() }}</p>
}
<ng-content select="[choiceActions]" />
</div>
</li>

View File

@@ -23,11 +23,18 @@ 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' },
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' },
args: {
heading: 'Herregistratie',
instructions: 'Referentie 2024-00123 · ingediend op 12 mei 2024',
},
};

View File

@@ -8,12 +8,19 @@ import { Component, input } from '@angular/core';
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">
<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() }}
<span class="visually-hidden">{{ successPrefix() }}</span
>{{ title() }}
</div>
</div>
<ng-content />

View File

@@ -12,7 +12,9 @@ import { HeadingComponent } from '@shared/ui/heading/heading.component';
selector: 'app-data-block',
imports: [HeadingComponent],
template: `
@if (heading()) { <app-heading [level]="level()">{{ heading() }}</app-heading> }
@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">

View File

@@ -15,10 +15,18 @@ import { Component, input } from '@angular/core';
// 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)}`],
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>
<dd class="col-md-8">
<ng-content>{{ value() }}</ng-content>
</dd>
`,
})
export class DataRowComponent {

View File

@@ -19,25 +19,53 @@ import { maskBsn, redactProfile } from './mask';
@Component({
selector: 'app-debug-state',
imports: [JsonPipe],
styles: [`
.fab{position:fixed;bottom:1rem;right:1rem;z-index:9999;font:600 12px/1 monospace;
background:var(--app-devpanel-bg);color:var(--app-devpanel-accent);
border:var(--rhc-border-width-sm) solid var(--app-devpanel-border);border-radius:4px;
padding:.5rem .75rem;cursor:pointer}
.panel{position:fixed;bottom:3.25rem;right:1rem;z-index:9999;width:min(90vw,28rem);
max-height:70vh;overflow:auto;background:var(--app-devpanel-bg);color:var(--app-devpanel-fg);
border:var(--rhc-border-width-sm) solid var(--app-devpanel-border);
border-radius:6px;box-shadow:0 6px 24px var(--app-devpanel-shadow)}
.panel pre{margin:0;padding:.75rem;font:12px/1.5 monospace;white-space:pre-wrap;
word-break:break-word}
`],
styles: [
`
.fab {
position: fixed;
bottom: 1rem;
right: 1rem;
z-index: 9999;
font: 600 12px/1 monospace;
background: var(--app-devpanel-bg);
color: var(--app-devpanel-accent);
border: var(--rhc-border-width-sm) solid var(--app-devpanel-border);
border-radius: 4px;
padding: 0.5rem 0.75rem;
cursor: pointer;
}
.panel {
position: fixed;
bottom: 3.25rem;
right: 1rem;
z-index: 9999;
width: min(90vw, 28rem);
max-height: 70vh;
overflow: auto;
background: var(--app-devpanel-bg);
color: var(--app-devpanel-fg);
border: var(--rhc-border-width-sm) solid var(--app-devpanel-border);
border-radius: 6px;
box-shadow: 0 6px 24px var(--app-devpanel-shadow);
}
.panel pre {
margin: 0;
padding: 0.75rem;
font: 12px/1.5 monospace;
white-space: pre-wrap;
word-break: break-word;
}
`,
],
template: `
@if (isDev) {
<button type="button" class="fab" (click)="toggle()">
{{ visible() ? '× state' : '⚙ state' }}
</button>
@if (visible()) {
<div class="panel"><pre>{{ snapshot() | json }}</pre></div>
<div class="panel">
<pre>{{ snapshot() | json }}</pre>
</div>
}
}
`,

View File

@@ -9,14 +9,18 @@ import { Component, booleanAttribute, input } from '@angular/core';
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>
<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 [id]="fieldId() + '-error'" role="alert">
<span class="errortext">{{ error() }}</span>
</div>
}
</div>
</div>

View File

@@ -25,5 +25,10 @@ export const Default: Story = {
args: { label: 'BSN', fieldId: 'bsn', description: '9 cijfers', required: true },
};
export const WithError: Story = {
args: { label: 'Straat en huisnummer', fieldId: 'street', error: 'Dit veld is verplicht.', required: true },
args: {
label: 'Straat en huisnummer',
fieldId: 'street',
error: 'Dit veld is verplicht.',
required: true,
},
};

View File

@@ -10,11 +10,21 @@ import { NgTemplateOutlet } from '@angular/common';
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> }
@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>
}
}
`,
})

View File

@@ -8,21 +8,52 @@ import { Component, computed, input } from '@angular/core';
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>`,
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>();
@@ -41,7 +72,12 @@ export class PlaceholderChipComponent {
});
protected ariaLabel = computed(() => {
const status = { auto: this.autoText(), manual: this.manualText(), warning: this.warningText(), error: this.errorText() }[this.variant()];
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:`;
});
}

View File

@@ -12,7 +12,15 @@ const meta: Meta<PlaceholderChipComponent> = {
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' } };
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' },
};

View File

@@ -23,7 +23,8 @@ export const JA_NEE: RadioOption[] = [
role="radiogroup"
[attr.aria-labelledby]="name() + '-label'"
[attr.aria-invalid]="invalid() ? 'true' : null"
[attr.aria-describedby]="invalid() ? name() + '-error' : null">
[attr.aria-describedby]="invalid() ? name() + '-error' : null"
>
@for (opt of options(); track opt.value) {
<div class="form-check styled">
<input
@@ -36,13 +37,16 @@ export const JA_NEE: RadioOption[] = [
[checked]="value === opt.value"
[disabled]="disabled"
(change)="select(opt.value)"
(blur)="onTouched()" />
(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 }],
providers: [
{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => RadioGroupComponent), multi: true },
],
})
export class RadioGroupComponent implements ControlValueAccessor {
options = input.required<RadioOption[]>();
@@ -58,8 +62,16 @@ export class RadioGroupComponent implements ControlValueAccessor {
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; }
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;
}
}

View File

@@ -13,5 +13,11 @@ export default meta;
type Story = StoryObj<RadioGroupComponent>;
export const JaNee: Story = {
args: { name: 'voorbeeld', options: [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }] },
args: {
name: 'voorbeeld',
options: [
{ value: 'ja', label: 'Ja' },
{ value: 'nee', label: 'Nee' },
],
},
};

View File

@@ -13,7 +13,9 @@ import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
<h2>{{ heading() }}</h2>
@if (showEdit()) {
<div class="ms-auto">
<a href="#" [attr.aria-label]="editAriaLabel() || editLabel()" (click)="onEdit($event)">{{ editLabel() }}</a>
<a href="#" [attr.aria-label]="editAriaLabel() || editLabel()" (click)="onEdit($event)">{{
editLabel()
}}</a>
</div>
}
</div>

View File

@@ -23,7 +23,12 @@ describe('rich-text DOM boundary', () => {
{ type: 'text', text: 'nieuwe regel' },
],
},
{ nodes: [{ type: 'text', text: 'Op ' }, { type: 'placeholder', key: 'datum' }] },
{
nodes: [
{ type: 'text', text: 'Op ' },
{ type: 'placeholder', key: 'datum' },
],
},
],
};
expect(roundTrip(block)).toEqual(block);
@@ -46,7 +51,9 @@ describe('rich-text DOM boundary', () => {
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'] }] }] });
expect(readBlock(root)).toEqual({
paragraphs: [{ nodes: [{ type: 'text', text: 'x', marks: ['bold', 'italic'] }] }],
});
});
it('round-trips bullet and numbered lists mixed with paragraphs', () => {
@@ -106,7 +113,16 @@ describe('rich-text DOM boundary', () => {
const root = document.createElement('div');
renderInto(
root,
{ paragraphs: [{ nodes: [{ type: 'placeholder', key: 'naam' }, { type: 'placeholder', key: 'reden' }] }] },
{
paragraphs: [
{
nodes: [
{ type: 'placeholder', key: 'naam' },
{ type: 'placeholder', key: 'reden' },
],
},
],
},
labelFor,
(key) => key === 'naam',
);

View File

@@ -76,9 +76,15 @@ export function createChip(doc: Document, key: string, label: string, auto = fal
return span;
}
function renderNode(node: RichTextNode, labelFor: (key: string) => string, doc: Document, autoFor?: (key: string) => boolean): Node {
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);
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))) {
@@ -124,7 +130,10 @@ function readLine(el: HTMLElement): { nodes: RichTextNode[] } {
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 });
if (text !== '')
out.push(
marks.length ? { type: 'text', text, marks: canonical(marks) } : { type: 'text', text },
);
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) return;
@@ -144,7 +153,11 @@ function collect(node: Node, marks: Mark[], out: RichTextNode[]): void {
}
function isChip(node: Node | null | undefined): node is HTMLElement {
return !!node && node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset?.['phKey'] != null;
return (
!!node &&
node.nodeType === Node.ELEMENT_NODE &&
(node as HTMLElement).dataset?.['phKey'] != null
);
}
/**
@@ -152,7 +165,11 @@ function isChip(node: Node | null | undefined): node is HTMLElement {
* (-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 {
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).

View File

@@ -23,41 +23,122 @@ export interface PlaceholderOption {
*/
@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)
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}
`],
: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-outline-secondary btn-sm" (mousedown)="$event.preventDefault()" (click)="format('bold')" [attr.aria-label]="boldLabel()"><b>B</b></button>
<button type="button" class="btn btn-outline-secondary btn-sm" (mousedown)="$event.preventDefault()" (click)="format('italic')" [attr.aria-label]="italicLabel()"><i>I</i></button>
<button type="button" class="btn btn-outline-secondary btn-sm" (mousedown)="$event.preventDefault()" (click)="format('underline')" [attr.aria-label]="underlineLabel()"><u>U</u></button>
<button
type="button"
class="btn btn-outline-secondary btn-sm"
(mousedown)="$event.preventDefault()"
(click)="format('bold')"
[attr.aria-label]="boldLabel()"
>
<b>B</b>
</button>
<button
type="button"
class="btn btn-outline-secondary btn-sm"
(mousedown)="$event.preventDefault()"
(click)="format('italic')"
[attr.aria-label]="italicLabel()"
>
<i>I</i>
</button>
<button
type="button"
class="btn btn-outline-secondary btn-sm"
(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-outline-secondary btn-sm" (mousedown)="$event.preventDefault()" (click)="list('bullet')" [attr.aria-label]="bulletListLabel()">•</button>
<button type="button" class="btn btn-outline-secondary btn-sm" (mousedown)="$event.preventDefault()" (click)="list('number')" [attr.aria-label]="numberListLabel()">1.</button>
<button
type="button"
class="btn btn-outline-secondary btn-sm"
(mousedown)="$event.preventDefault()"
(click)="list('bullet')"
[attr.aria-label]="bulletListLabel()"
>
</button>
<button
type="button"
class="btn btn-outline-secondary btn-sm"
(mousedown)="$event.preventDefault()"
(click)="list('number')"
[attr.aria-label]="numberListLabel()"
>
1.
</button>
@if (placeholders().length) {
<span class="rte-sep" aria-hidden="true"></span>
<label>
@@ -72,8 +153,16 @@ export interface PlaceholderOption {
}
</div>
}
<div #editor class="rte-editable" [attr.contenteditable]="editable()" (input)="emit()" (keydown)="onKeydown($event)"
role="textbox" aria-multiline="true" [attr.aria-label]="fieldLabel()"></div>
<div
#editor
class="rte-editable"
[attr.contenteditable]="editable()"
(input)="emit()"
(keydown)="onKeydown($event)"
role="textbox"
aria-multiline="true"
[attr.aria-label]="fieldLabel()"
></div>
`,
})
export class RichTextEditorComponent {
@@ -97,7 +186,8 @@ export class RichTextEditorComponent {
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;
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
@@ -149,7 +239,11 @@ export class RichTextEditorComponent {
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;
const cmd = { b: 'bold', i: 'italic', u: 'underline' }[e.key.toLowerCase()] as
| 'bold'
| 'italic'
| 'underline'
| undefined;
if (!cmd) return;
e.preventDefault();
this.format(cmd);
@@ -164,7 +258,11 @@ export class RichTextEditorComponent {
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);
const chip = adjacentChip(
range.startContainer,
range.startOffset,
e.key === 'Backspace' ? -1 : 1,
);
if (!chip) return;
e.preventDefault();
chip.remove();

View File

@@ -11,7 +11,13 @@ const sample: RichTextBlock = {
{ type: 'text', text: ',' },
],
},
{ nodes: [{ type: 'text', text: 'Op ' }, { type: 'placeholder', key: 'datum' }, { type: 'text', text: ' hebben wij besloten.' }] },
{
nodes: [
{ type: 'text', text: 'Op ' },
{ type: 'placeholder', key: 'datum' },
{ type: 'text', text: ' hebben wij besloten.' },
],
},
],
};
@@ -33,5 +39,7 @@ 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 Empty: Story = {
args: { content: { paragraphs: [{ nodes: [] }] }, placeholders, editable: true },
};
export const ReadOnly: Story = { args: { content: sample, placeholders, editable: false } };

View File

@@ -4,13 +4,33 @@ import { Component, OnDestroy, OnInit, computed, input, signal } from '@angular/
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}}
`],
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) {
@@ -28,6 +48,10 @@ export class SkeletonComponent implements OnInit, OnDestroy {
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); }
ngOnInit() {
this.timer = setTimeout(() => this.visible.set(true), this.delay());
}
ngOnDestroy() {
clearTimeout(this.timer);
}
}

View File

@@ -4,17 +4,35 @@ import { Component, OnDestroy, OnInit, input, signal } from '@angular/core';
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)}}
`],
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>
<div
class="sp"
role="status"
i18n-aria-label="@@spinner.aria"
aria-label="Bezig met laden"
></div>
}
`,
})
@@ -23,6 +41,10 @@ export class SpinnerComponent implements OnInit, OnDestroy {
protected visible = signal(false);
private timer?: ReturnType<typeof setTimeout>;
ngOnInit() { this.timer = setTimeout(() => this.visible.set(true), this.delay()); }
ngOnDestroy() { clearTimeout(this.timer); }
ngOnInit() {
this.timer = setTimeout(() => this.visible.set(true), this.delay());
}
ngOnDestroy() {
clearTimeout(this.timer);
}
}

View File

@@ -6,10 +6,21 @@ import { Component, input } from '@angular/core';
@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}
`],
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>

View File

@@ -8,6 +8,12 @@ const meta: Meta<StatusBadgeComponent> = {
export default meta;
type Story = StoryObj<StatusBadgeComponent>;
export const Geregistreerd: Story = { args: { label: 'Geregistreerd', color: 'var(--rhc-color-groen-500)' } };
export const Geschorst: Story = { args: { label: 'Geschorst', color: 'var(--rhc-color-oranje-500)' } };
export const Doorgehaald: Story = { args: { label: 'Doorgehaald', color: 'var(--rhc-color-rood-500)' } };
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)' },
};

View File

@@ -12,12 +12,21 @@ import { Component, ElementRef, input, output, viewChild } from '@angular/core';
@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)">
<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="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>
@@ -30,8 +39,13 @@ import { Component, ElementRef, input, output, viewChild } from '@angular/core';
}
</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>
@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>
`,

View File

@@ -13,8 +13,15 @@ export default meta;
type Story = StoryObj<StepperComponent>;
const steps = ['Adres', 'Beroep', 'Controle'];
const base = { steps, processName: 'Inschrijven in het BIG-register', stepTitle: '', stepSelected: () => {} };
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 Midden: Story = {
args: { ...base, current: 1, stepTitle: 'Beroep op basis van uw diploma' },
};
export const Laatste: Story = { args: { ...base, current: 2 } };

View File

@@ -7,7 +7,10 @@ const meta: Meta<TaskListComponent> = {
title: 'Molecules/Task List',
component: TaskListComponent,
decorators: [applicationConfig({ providers: [provideRouter([])] })],
render: (args) => ({ props: args, template: `<app-task-list [listHeading]="listHeading" [tasks]="tasks" />` }),
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`.
@@ -22,8 +25,18 @@ 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' },
{
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',
},
],
},
};

View File

@@ -4,10 +4,17 @@ 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}
`],
styles: [
`
:host {
display: block;
}
input {
inline-size: 100%;
box-sizing: border-box;
}
`,
],
template: `
<input
class="form-control"
@@ -20,9 +27,12 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
[disabled]="disabled"
[value]="value"
(input)="onInput($event)"
(blur)="onTouched()" />
(blur)="onTouched()"
/>
`,
providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => TextInputComponent), multi: true }],
providers: [
{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => TextInputComponent), multi: true },
],
})
export class TextInputComponent implements ControlValueAccessor {
type = input<'text' | 'password' | 'email'>('text');
@@ -39,8 +49,16 @@ export class TextInputComponent implements ControlValueAccessor {
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; }
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;
}
}

View File

@@ -5,16 +5,21 @@ import type { DeliveryChannel } from '@shared/upload/upload.machine';
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; }
`],
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) {
@@ -26,7 +31,8 @@ import type { DeliveryChannel } from '@shared/upload/upload.machine';
[value]="opt.value"
[checked]="channel() === opt.value"
[disabled]="disabled()"
(change)="channelChange.emit(opt.value)" />
(change)="channelChange.emit(opt.value)"
/>
{{ opt.label }}
</label>
}

View File

@@ -12,5 +12,7 @@ const meta: Meta<DeliveryChannelToggleComponent> = {
export default meta;
type Story = StoryObj<DeliveryChannelToggleComponent>;
export const Digital: Story = { args: { channel: 'digital', name: 'diploma-channel', disabled: false } };
export const Digital: Story = {
args: { channel: 'digital', name: 'diploma-channel', disabled: false },
};
export const Post: Story = { args: { channel: 'post', name: 'diploma-channel', disabled: false } };

View File

@@ -11,16 +11,37 @@ import { SingleUploadComponent } from '../single-upload/single-upload.component'
@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); }
`],
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> }
{{ category().label }}
@if (category().required) {
<span class="req" i18n="@@upload.category.required">(verplicht)</span>
}
</div>
@if (category().description) {
<div class="desc">{{ category().description }}</div>
@@ -30,7 +51,8 @@ import { SingleUploadComponent } from '../single-upload/single-upload.component'
<app-delivery-channel-toggle
[channel]="channel()"
[name]="category().categoryId + '-channel'"
(channelChange)="channelChange.emit($event)" />
(channelChange)="channelChange.emit($event)"
/>
}
@if (channel() === 'digital') {
@@ -47,18 +69,21 @@ import { SingleUploadComponent } from '../single-upload/single-upload.component'
[accept]="category().acceptedTypes"
[maxSizeMb]="category().maxSizeMb"
[multiple]="category().multiple"
(filesSelected)="fileSelected.emit($event)" />
(filesSelected)="fileSelected.emit($event)"
/>
@if (uploads().length) {
<ul class="file-list">
@for (u of uploads(); track u.localId) {
<li app-single-upload
<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>
(retry)="retryUpload.emit(u.localId)"
></li>
}
</ul>
}
@@ -73,7 +98,10 @@ export class DocumentCategoryComponent {
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:`);
protected fileInputLabel = computed(
() =>
$localize`:@@upload.fileInput.labelFor:Bestand kiezen voor ${this.category().label}:category:`,
);
fileSelected = output<File[]>();
removeUpload = output<string>();

View File

@@ -40,5 +40,10 @@ export const Digital: Story = {
};
export const WithRejection: Story = {
args: { category, uploads: [], channel: 'digital', rejection: 'Dit bestandstype is niet toegestaan voor deze categorie.' },
args: {
category,
uploads: [],
channel: 'digital',
rejection: 'Dit bestandstype is niet toegestaan voor deze categorie.',
},
};

View File

@@ -19,17 +19,30 @@ const STATUS_LABELS: Record<UploadStatus['type'], string> = {
@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; }
`],
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>
<a class="file-name" [href]="previewUrl()" target="_blank" rel="noopener">{{
fileName()
}}</a>
} @else {
<span class="file-name">{{ fileName() }}</span>
}

View File

@@ -15,13 +15,26 @@ 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 },
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' },
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 },
args: {
fileName: 'diploma.pdf',
fileSizeMb: 1.2,
status: { type: 'failed', reason: 'Netwerkfout' } as UploadStatus,
},
};

View File

@@ -9,9 +9,15 @@ import { UploadStatusBannerComponent } from '../upload-status-banner/upload-stat
@Component({
selector: 'app-document-upload',
imports: [DocumentCategoryComponent, UploadStatusBannerComponent],
styles: [`
:host { display: flex; flex-direction: column; gap: var(--rhc-space-max-xl); }
`],
styles: [
`
:host {
display: flex;
flex-direction: column;
gap: var(--rhc-space-max-xl);
}
`,
],
template: `
@if (state().categoriesError) {
<app-upload-status-banner type="error" [message]="state().categoriesError!" />
@@ -30,7 +36,8 @@ import { UploadStatusBannerComponent } from '../upload-status-banner/upload-stat
(removeUpload)="removeUpload.emit($event)"
(retryUpload)="retryUpload.emit($event)"
(deleteUpload)="deleteUpload.emit($event)"
(channelChange)="channelChange.emit({ categoryId: c.categoryId, channel: $event })" />
(channelChange)="channelChange.emit({ categoryId: c.categoryId, channel: $event })"
/>
}
}
`,

View File

@@ -21,21 +21,47 @@ const TYPE_LABELS: Record<string, string> = {
`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
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; }
`],
.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)">
<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>
}
@@ -49,8 +75,13 @@ const TYPE_LABELS: Record<string, string> = {
[accept]="accept().join(',')"
[multiple]="multiple()"
[disabled]="disabled()"
(change)="onChange($event)" />
<label class="btn btn-primary btn-upload" [class.disabled]="disabled()" [attr.for]="inputId()">
(change)="onChange($event)"
/>
<label
class="btn btn-primary btn-upload"
[class.disabled]="disabled()"
[attr.for]="inputId()"
>
{{ buttonText() }}
</label>
</div>
@@ -80,9 +111,12 @@ export class FileInputComponent {
/** 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 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 && 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 '';

View File

@@ -13,13 +13,31 @@ 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 },
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 },
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 },
args: {
inputId: 'diploma-disabled',
accept: ['application/pdf'],
maxSizeMb: 10,
multiple: false,
disabled: true,
},
};

View File

@@ -11,24 +11,55 @@ import { UploadProgressBarComponent } from '../upload-progress-bar/upload-progre
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; }
`],
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()" />
[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>
<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>
<button
type="button"
class="btn icon-remove"
[attr.aria-label]="removeLabel"
(click)="remove.emit()"
></button>
}
</div>
@if (progressPct() !== null) {

View File

@@ -4,16 +4,27 @@ import { Component, input } from '@angular/core';
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; }
`],
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>
<progress max="100" [value]="progressPct()" [attr.aria-label]="progressLabel"></progress>
<span class="pct">{{ progressPct() }}%</span>
`,
})

View File

@@ -19,5 +19,7 @@ export class UploadStatusBannerComponent {
message = input.required<string>();
type = input<BannerType>('info');
protected readonly alertType = computed<AlertType>(() => (this.type() === 'info' ? 'info' : this.type()));
protected readonly alertType = computed<AlertType>(() =>
this.type() === 'info' ? 'info' : this.type(),
);
}

View File

@@ -11,12 +11,21 @@ interface Glyph {
label derive purely from the status type. */
@Component({
selector: 'app-upload-status-icon',
styles: [`
.glyph { font-weight: var(--rhc-text-font-weight-semi-bold); }
`],
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">
<span
class="glyph"
[style.color]="glyph()!.color"
[attr.aria-label]="glyph()!.label"
role="img"
>
{{ glyph()!.char }}
</span>
}
@@ -28,15 +37,35 @@ export class UploadStatusIconComponent {
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)' };
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)' };
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)' };
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)' };
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)' };
return {
char: '⟳',
label: $localize`:@@upload.status.deleting:Bezig met verwijderen`,
color: 'var(--rhc-color-foreground-subtle)',
};
case 'idle':
case 'deleted':
return null;