Upload feature (c): atomic UI components (atoms/molecules/organisms) + stories

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 06:25:30 +02:00
parent c4bfe9d39b
commit 9521739ac1
18 changed files with 677 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
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: [`
.rhc-radio-option {
display: flex;
align-items: center;
gap: var(--rhc-space-max-md);
padding-block: var(--rhc-space-max-sm);
}
`],
template: `
<div class="utrecht-form-field-radio-group" role="radiogroup">
@for (opt of options; track opt.value) {
<label class="utrecht-form-label utrecht-form-label--radio-button rhc-radio-option">
<input
class="utrecht-radio-button"
[class.utrecht-radio-button--checked]="channel() === opt.value"
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` },
];
}

View File

@@ -0,0 +1,16 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { DeliveryChannelToggleComponent } from './delivery-channel-toggle.component';
const meta: Meta<DeliveryChannelToggleComponent> = {
title: '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 } };

View File

@@ -0,0 +1,83 @@
import { Component, 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 — its label/description, an optional delivery
channel toggle, and (when digital) a file picker plus the 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); }
.uploads { display: flex; flex-direction: column; gap: var(--rhc-space-max-md); margin-block-start: var(--rhc-space-max-md); }
.rejection {
color: var(--rhc-color-foreground-default);
font-weight: var(--rhc-text-font-weight-semi-bold);
margin-block-start: var(--rhc-space-max-sm);
border-inline-start: var(--rhc-border-width-md) solid var(--rhc-color-rood-500);
padding-inline-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') {
<app-file-input
[inputId]="category().categoryId + '-file'"
[accept]="category().acceptedTypes"
[multiple]="category().multiple"
(filesSelected)="fileSelected.emit($event)" />
<div class="uploads">
@for (u of uploads(); track u.localId) {
<app-single-upload
[upload]="u"
(remove)="onRemove(u)"
(retry)="retryUpload.emit(u.localId)" />
}
</div>
@if (rejection()) {
<div class="rejection" role="alert">{{ rejection() }}</div>
}
}
`,
})
export class DocumentCategoryComponent {
category = input.required<DocumentCategory>();
uploads = input.required<Upload[]>();
channel = input.required<DeliveryChannel>();
rejection = input<string>();
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);
}
}
}

View File

@@ -0,0 +1,44 @@
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: '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.' },
};

View File

@@ -0,0 +1,61 @@
import { Component, input, output } from '@angular/core';
import type { UploadStatus } from '@shared/upload/upload.machine';
import { UploadStatusIconComponent } from '../upload-status-icon/upload-status-icon.component';
/** Atom: a single uploaded-file chip — filename + status glyph + actions. Pure UI:
emits `remove`/`retry`; the container decides what they mean. */
@Component({
selector: 'app-document-chip',
imports: [UploadStatusIconComponent],
styles: [`
:host {
display: inline-flex;
align-items: center;
gap: var(--rhc-space-max-md);
padding-block: var(--rhc-space-max-sm);
padding-inline: var(--rhc-space-max-md);
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-subtle);
border-radius: var(--rhc-border-radius-md);
background: var(--rhc-color-cool-grey-100);
}
.name { flex: 1; }
.action {
background: none;
border: none;
cursor: pointer;
padding: 0;
color: var(--rhc-color-foreground-link);
font: inherit;
text-decoration: underline;
}
.action:hover { color: var(--rhc-color-foreground-link-hover); }
`],
template: `
<app-upload-status-icon [status]="status().type" />
<span class="name">{{ fileName() }}</span>
@if (status().type === 'failed') {
<button type="button" class="action" [attr.aria-label]="retryLabel" (click)="retry.emit()">
<span i18n="@@upload.chip.retry">Opnieuw</span>
</button>
}
@if (status().type !== 'deleting') {
<button type="button" class="action" [attr.aria-label]="removeLabel" (click)="remove.emit()">
<span i18n="@@upload.chip.remove">Verwijderen</span>
</button>
}
`,
})
export class DocumentChipComponent {
fileName = input.required<string>();
status = input.required<UploadStatus>();
remove = output<void>();
retry = output<void>();
protected get removeLabel(): string {
return $localize`:@@upload.chip.removeAria:${this.fileName()}:fileName: verwijderen`;
}
protected get retryLabel(): string {
return $localize`:@@upload.chip.retryAria:${this.fileName()}:fileName: opnieuw uploaden`;
}
}

View File

@@ -0,0 +1,22 @@
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: 'Atoms/DocumentChip',
component: DocumentChipComponent,
render: (args) => ({
props: args,
template: `<app-document-chip [fileName]="fileName" [status]="status" />`,
}),
};
export default meta;
type Story = StoryObj<DocumentChipComponent>;
export const Complete: Story = {
args: { fileName: 'diploma.pdf', status: { type: 'complete', documentId: 'doc-1' } as UploadStatus },
};
export const Failed: Story = {
args: { fileName: 'diploma.pdf', status: { type: 'failed', reason: 'Netwerkfout' } as UploadStatus },
};

View File

@@ -0,0 +1,54 @@
import { Component, input, output } from '@angular/core';
import type { DeliveryChannel, UploadState } from '@shared/upload/upload.machine';
import { DocumentCategoryComponent } from '../document-category/document-category.component';
import { UploadStatusBannerComponent } from '../upload-status-banner/upload-status-banner.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, UploadStatusBannerComponent],
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!" />
} @else {
@if (state().backgroundSyncAvailable === false) {
<app-upload-status-banner type="info" [message]="foregroundOnlyMessage" />
}
@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]"
(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>();
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';
}
}

View File

@@ -0,0 +1,58 @@
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: '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.' } },
};

View File

@@ -0,0 +1,61 @@
import { Component, ElementRef, input, output, viewChild } from '@angular/core';
/** Atom: a styled file picker. Wraps a native `<input type="file">` and emits the
selected files; resets its value after each change so re-picking the same file
re-fires. Pure UI — no validation, no upload. */
@Component({
selector: 'app-file-input',
styles: [`
:host { display: inline-block; }
.field { position: relative; display: inline-flex; }
input[type='file'] {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
input[type='file']:disabled { cursor: not-allowed; }
.label {
pointer-events: none;
display: inline-flex;
align-items: center;
gap: var(--rhc-space-max-sm);
}
`],
template: `
<span class="field">
<input
#fileInput
type="file"
[id]="inputId()"
[accept]="accept().join(',')"
[multiple]="multiple()"
[disabled]="disabled()"
(change)="onChange($event)" />
<span
class="utrecht-button rhc-button utrecht-button--secondary-action label"
[class.utrecht-button--disabled]="disabled()">
<span aria-hidden="true">↑</span>
<span i18n="@@upload.fileInput.label">Bestand kiezen</span>
</span>
</span>
`,
})
export class FileInputComponent {
accept = input<string[]>([]);
multiple = input(false);
disabled = input(false);
inputId = input.required<string>();
filesSelected = output<File[]>();
private fileInput = viewChild.required<ElementRef<HTMLInputElement>>('fileInput');
onChange(event: Event) {
const input = event.target as HTMLInputElement;
this.filesSelected.emit(Array.from(input.files ?? []));
this.fileInput().nativeElement.value = '';
}
}

View File

@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { FileInputComponent } from './file-input.component';
const meta: Meta<FileInputComponent> = {
title: 'Atoms/FileInput',
component: FileInputComponent,
render: (args) => ({
props: args,
template: `<app-file-input [inputId]="inputId" [accept]="accept" [multiple]="multiple" [disabled]="disabled" />`,
}),
};
export default meta;
type Story = StoryObj<FileInputComponent>;
export const Default: Story = {
args: { inputId: 'diploma', accept: ['application/pdf'], multiple: false, disabled: false },
};
export const Disabled: Story = {
args: { inputId: 'diploma-disabled', accept: ['application/pdf'], multiple: false, disabled: true },
};

View File

@@ -0,0 +1,36 @@
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 upload row — its chip plus a progress bar while uploading. Pure UI:
forwards `remove`/`retry` from the chip. */
@Component({
selector: 'app-single-upload',
imports: [DocumentChipComponent, UploadProgressBarComponent],
styles: [`
:host { display: flex; flex-direction: column; gap: var(--rhc-space-max-sm); }
`],
template: `
<app-document-chip
[fileName]="upload().fileName"
[status]="upload().status"
(remove)="remove.emit()"
(retry)="retry.emit()" />
@if (progressPct() !== null) {
<app-upload-progress-bar [progressPct]="progressPct()!" />
}
`,
})
export class SingleUploadComponent {
upload = input.required<Upload>();
/** 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;
});
remove = output<void>();
retry = output<void>();
}

View File

@@ -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: 'Molecules/SingleUpload',
component: SingleUploadComponent,
render: (args) => ({
props: args,
template: `<app-single-upload [upload]="upload" />`,
}),
};
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 } } },
};

View File

@@ -0,0 +1,24 @@
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`;
}

View File

@@ -0,0 +1,16 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { UploadProgressBarComponent } from './upload-progress-bar.component';
const meta: Meta<UploadProgressBarComponent> = {
title: '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 } };

View File

@@ -0,0 +1,23 @@
import { Component, computed, input } from '@angular/core';
import { AlertComponent } from '@shared/ui/alert/alert.component';
type BannerType = 'info' | 'warning' | 'error';
type AlertType = 'info' | 'ok' | 'warning' | 'error';
/** Molecule: a polite, announced status banner. Wraps the alert atom and maps the
banner type to an alert type. Pure UI: the container computes the message. */
@Component({
selector: 'app-upload-status-banner',
imports: [AlertComponent],
template: `
<div aria-live="polite">
<app-alert [type]="alertType()">{{ message() }}</app-alert>
</div>
`,
})
export class UploadStatusBannerComponent {
message = input.required<string>();
type = input<BannerType>('info');
protected readonly alertType = computed<AlertType>(() => (this.type() === 'info' ? 'info' : this.type()));
}

View File

@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { UploadStatusBannerComponent } from './upload-status-banner.component';
const meta: Meta<UploadStatusBannerComponent> = {
title: 'Molecules/UploadStatusBanner',
component: UploadStatusBannerComponent,
render: (args) => ({
props: args,
template: `<app-upload-status-banner [message]="message" [type]="type" />`,
}),
};
export default meta;
type Story = StoryObj<UploadStatusBannerComponent>;
export const Info: Story = {
args: { type: 'info', message: 'Uw documenten worden geüpload.' },
};
export const Error: Story = {
args: { type: 'error', message: 'De categorieën konden niet worden geladen.' },
};

View File

@@ -0,0 +1,45 @@
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;
}
});
}

View File

@@ -0,0 +1,17 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { UploadStatusIconComponent } from './upload-status-icon.component';
const meta: Meta<UploadStatusIconComponent> = {
title: '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' } };