diff --git a/src/app/shared/ui/upload/delivery-channel-toggle/delivery-channel-toggle.component.ts b/src/app/shared/ui/upload/delivery-channel-toggle/delivery-channel-toggle.component.ts
new file mode 100644
index 0000000..d62657f
--- /dev/null
+++ b/src/app/shared/ui/upload/delivery-channel-toggle/delivery-channel-toggle.component.ts
@@ -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: `
+
+ @for (opt of options; track opt.value) {
+
+ }
+
+ `,
+})
+export class DeliveryChannelToggleComponent {
+ channel = input.required();
+ name = input.required();
+ disabled = input(false);
+
+ channelChange = output();
+
+ 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` },
+ ];
+}
diff --git a/src/app/shared/ui/upload/delivery-channel-toggle/delivery-channel-toggle.stories.ts b/src/app/shared/ui/upload/delivery-channel-toggle/delivery-channel-toggle.stories.ts
new file mode 100644
index 0000000..940872d
--- /dev/null
+++ b/src/app/shared/ui/upload/delivery-channel-toggle/delivery-channel-toggle.stories.ts
@@ -0,0 +1,16 @@
+import type { Meta, StoryObj } from '@storybook/angular';
+import { DeliveryChannelToggleComponent } from './delivery-channel-toggle.component';
+
+const meta: Meta = {
+ title: 'Atoms/DeliveryChannelToggle',
+ component: DeliveryChannelToggleComponent,
+ render: (args) => ({
+ props: args,
+ template: ``,
+ }),
+};
+export default meta;
+type Story = StoryObj;
+
+export const Digital: Story = { args: { channel: 'digital', name: 'diploma-channel', disabled: false } };
+export const Post: Story = { args: { channel: 'post', name: 'diploma-channel', disabled: false } };
diff --git a/src/app/shared/ui/upload/document-category/document-category.component.ts b/src/app/shared/ui/upload/document-category/document-category.component.ts
new file mode 100644
index 0000000..77473e3
--- /dev/null
+++ b/src/app/shared/ui/upload/document-category/document-category.component.ts
@@ -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: `
+
+ {{ category().label }}@if (category().required) { (verplicht) }
+
+ @if (category().description) {
+ {{ category().description }}
+ }
+
+ @if (category().allowPostDelivery) {
+
+ }
+
+ @if (channel() === 'digital') {
+
+
+
+ @for (u of uploads(); track u.localId) {
+
+ }
+
+
+ @if (rejection()) {
+ {{ rejection() }}
+ }
+ }
+ `,
+})
+export class DocumentCategoryComponent {
+ category = input.required();
+ uploads = input.required();
+ channel = input.required();
+ rejection = input();
+
+ fileSelected = output();
+ removeUpload = output();
+ retryUpload = output();
+ deleteUpload = output<{ localId: string; documentId: string }>();
+ channelChange = output();
+
+ onRemove(u: Upload) {
+ if (u.status.type === 'complete') {
+ this.deleteUpload.emit({ localId: u.localId, documentId: u.status.documentId });
+ } else {
+ this.removeUpload.emit(u.localId);
+ }
+ }
+}
diff --git a/src/app/shared/ui/upload/document-category/document-category.stories.ts b/src/app/shared/ui/upload/document-category/document-category.stories.ts
new file mode 100644
index 0000000..8fcdd0c
--- /dev/null
+++ b/src/app/shared/ui/upload/document-category/document-category.stories.ts
@@ -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 = {
+ title: 'Organisms/DocumentCategory',
+ component: DocumentCategoryComponent,
+ render: (args) => ({
+ props: args,
+ template: ``,
+ }),
+};
+export default meta;
+type Story = StoryObj;
+
+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.' },
+};
diff --git a/src/app/shared/ui/upload/document-chip/document-chip.component.ts b/src/app/shared/ui/upload/document-chip/document-chip.component.ts
new file mode 100644
index 0000000..72d866a
--- /dev/null
+++ b/src/app/shared/ui/upload/document-chip/document-chip.component.ts
@@ -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: `
+
+ {{ fileName() }}
+ @if (status().type === 'failed') {
+
+ }
+ @if (status().type !== 'deleting') {
+
+ }
+ `,
+})
+export class DocumentChipComponent {
+ fileName = input.required();
+ status = input.required();
+
+ remove = output();
+ retry = output();
+
+ 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`;
+ }
+}
diff --git a/src/app/shared/ui/upload/document-chip/document-chip.stories.ts b/src/app/shared/ui/upload/document-chip/document-chip.stories.ts
new file mode 100644
index 0000000..f9a681c
--- /dev/null
+++ b/src/app/shared/ui/upload/document-chip/document-chip.stories.ts
@@ -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 = {
+ title: 'Atoms/DocumentChip',
+ component: DocumentChipComponent,
+ render: (args) => ({
+ props: args,
+ template: ``,
+ }),
+};
+export default meta;
+type Story = StoryObj;
+
+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 },
+};
diff --git a/src/app/shared/ui/upload/document-upload/document-upload.component.ts b/src/app/shared/ui/upload/document-upload/document-upload.component.ts
new file mode 100644
index 0000000..0dbd3ec
--- /dev/null
+++ b/src/app/shared/ui/upload/document-upload/document-upload.component.ts
@@ -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) {
+
+ } @else {
+ @if (state().backgroundSyncAvailable === false) {
+
+ }
+ @for (c of state().categories; track c.categoryId) {
+
+ }
+ }
+ `,
+})
+export class DocumentUploadComponent {
+ state = input.required();
+
+ fileSelected = output<{ categoryId: string; files: File[] }>();
+ removeUpload = output();
+ retryUpload = output();
+ 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';
+ }
+}
diff --git a/src/app/shared/ui/upload/document-upload/document-upload.stories.ts b/src/app/shared/ui/upload/document-upload/document-upload.stories.ts
new file mode 100644
index 0000000..b50c203
--- /dev/null
+++ b/src/app/shared/ui/upload/document-upload/document-upload.stories.ts
@@ -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 = {
+ title: 'Organisms/DocumentUpload',
+ component: DocumentUploadComponent,
+ render: (args) => ({
+ props: args,
+ template: ``,
+ }),
+};
+export default meta;
+type Story = StoryObj;
+
+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.' } },
+};
diff --git a/src/app/shared/ui/upload/file-input/file-input.component.ts b/src/app/shared/ui/upload/file-input/file-input.component.ts
new file mode 100644
index 0000000..2e17aef
--- /dev/null
+++ b/src/app/shared/ui/upload/file-input/file-input.component.ts
@@ -0,0 +1,61 @@
+import { Component, ElementRef, input, output, viewChild } from '@angular/core';
+
+/** Atom: a styled file picker. Wraps a native `` 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: `
+
+
+
+ ↑
+ Bestand kiezen
+
+
+ `,
+})
+export class FileInputComponent {
+ accept = input([]);
+ multiple = input(false);
+ disabled = input(false);
+ inputId = input.required();
+
+ filesSelected = output();
+
+ private fileInput = viewChild.required>('fileInput');
+
+ onChange(event: Event) {
+ const input = event.target as HTMLInputElement;
+ this.filesSelected.emit(Array.from(input.files ?? []));
+ this.fileInput().nativeElement.value = '';
+ }
+}
diff --git a/src/app/shared/ui/upload/file-input/file-input.stories.ts b/src/app/shared/ui/upload/file-input/file-input.stories.ts
new file mode 100644
index 0000000..b6cabf4
--- /dev/null
+++ b/src/app/shared/ui/upload/file-input/file-input.stories.ts
@@ -0,0 +1,21 @@
+import type { Meta, StoryObj } from '@storybook/angular';
+import { FileInputComponent } from './file-input.component';
+
+const meta: Meta = {
+ title: 'Atoms/FileInput',
+ component: FileInputComponent,
+ render: (args) => ({
+ props: args,
+ template: ``,
+ }),
+};
+export default meta;
+type Story = StoryObj;
+
+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 },
+};
diff --git a/src/app/shared/ui/upload/single-upload/single-upload.component.ts b/src/app/shared/ui/upload/single-upload/single-upload.component.ts
new file mode 100644
index 0000000..669cae2
--- /dev/null
+++ b/src/app/shared/ui/upload/single-upload/single-upload.component.ts
@@ -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: `
+
+ @if (progressPct() !== null) {
+
+ }
+ `,
+})
+export class SingleUploadComponent {
+ upload = input.required();
+
+ /** Narrow the status union once, in TS, so the template stays type-safe. */
+ protected readonly progressPct = computed(() => {
+ const status = this.upload().status;
+ return status.type === 'uploading' ? status.progressPct : null;
+ });
+
+ remove = output();
+ retry = output();
+}
diff --git a/src/app/shared/ui/upload/single-upload/single-upload.stories.ts b/src/app/shared/ui/upload/single-upload/single-upload.stories.ts
new file mode 100644
index 0000000..66c5833
--- /dev/null
+++ b/src/app/shared/ui/upload/single-upload/single-upload.stories.ts
@@ -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 = {
+ title: 'Molecules/SingleUpload',
+ component: SingleUploadComponent,
+ render: (args) => ({
+ props: args,
+ template: ``,
+ }),
+};
+export default meta;
+type Story = StoryObj;
+
+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 } } },
+};
diff --git a/src/app/shared/ui/upload/upload-progress-bar/upload-progress-bar.component.ts b/src/app/shared/ui/upload/upload-progress-bar/upload-progress-bar.component.ts
new file mode 100644
index 0000000..5b03c1d
--- /dev/null
+++ b/src/app/shared/ui/upload/upload-progress-bar/upload-progress-bar.component.ts
@@ -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: `
+
+ {{ progressPct() }}%
+ `,
+})
+export class UploadProgressBarComponent {
+ progressPct = input.required();
+
+ protected readonly progressLabel = $localize`:@@upload.progress.label:Uploadvoortgang`;
+}
diff --git a/src/app/shared/ui/upload/upload-progress-bar/upload-progress-bar.stories.ts b/src/app/shared/ui/upload/upload-progress-bar/upload-progress-bar.stories.ts
new file mode 100644
index 0000000..5b83706
--- /dev/null
+++ b/src/app/shared/ui/upload/upload-progress-bar/upload-progress-bar.stories.ts
@@ -0,0 +1,16 @@
+import type { Meta, StoryObj } from '@storybook/angular';
+import { UploadProgressBarComponent } from './upload-progress-bar.component';
+
+const meta: Meta = {
+ title: 'Atoms/UploadProgressBar',
+ component: UploadProgressBarComponent,
+ render: (args) => ({
+ props: args,
+ template: ``,
+ }),
+};
+export default meta;
+type Story = StoryObj;
+
+export const Halfway: Story = { args: { progressPct: 45 } };
+export const Almost: Story = { args: { progressPct: 92 } };
diff --git a/src/app/shared/ui/upload/upload-status-banner/upload-status-banner.component.ts b/src/app/shared/ui/upload/upload-status-banner/upload-status-banner.component.ts
new file mode 100644
index 0000000..13fa187
--- /dev/null
+++ b/src/app/shared/ui/upload/upload-status-banner/upload-status-banner.component.ts
@@ -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: `
+
+ `,
+})
+export class UploadStatusBannerComponent {
+ message = input.required();
+ type = input('info');
+
+ protected readonly alertType = computed(() => (this.type() === 'info' ? 'info' : this.type()));
+}
diff --git a/src/app/shared/ui/upload/upload-status-banner/upload-status-banner.stories.ts b/src/app/shared/ui/upload/upload-status-banner/upload-status-banner.stories.ts
new file mode 100644
index 0000000..124f1c5
--- /dev/null
+++ b/src/app/shared/ui/upload/upload-status-banner/upload-status-banner.stories.ts
@@ -0,0 +1,21 @@
+import type { Meta, StoryObj } from '@storybook/angular';
+import { UploadStatusBannerComponent } from './upload-status-banner.component';
+
+const meta: Meta = {
+ title: 'Molecules/UploadStatusBanner',
+ component: UploadStatusBannerComponent,
+ render: (args) => ({
+ props: args,
+ template: ``,
+ }),
+};
+export default meta;
+type Story = StoryObj;
+
+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.' },
+};
diff --git a/src/app/shared/ui/upload/upload-status-icon/upload-status-icon.component.ts b/src/app/shared/ui/upload/upload-status-icon/upload-status-icon.component.ts
new file mode 100644
index 0000000..45a328e
--- /dev/null
+++ b/src/app/shared/ui/upload/upload-status-icon/upload-status-icon.component.ts
@@ -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()) {
+
+ {{ glyph()!.char }}
+
+ }
+ `,
+})
+export class UploadStatusIconComponent {
+ status = input.required();
+
+ protected readonly glyph = computed(() => {
+ 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;
+ }
+ });
+}
diff --git a/src/app/shared/ui/upload/upload-status-icon/upload-status-icon.stories.ts b/src/app/shared/ui/upload/upload-status-icon/upload-status-icon.stories.ts
new file mode 100644
index 0000000..67eb2a1
--- /dev/null
+++ b/src/app/shared/ui/upload/upload-status-icon/upload-status-icon.stories.ts
@@ -0,0 +1,17 @@
+import type { Meta, StoryObj } from '@storybook/angular';
+import { UploadStatusIconComponent } from './upload-status-icon.component';
+
+const meta: Meta = {
+ title: 'Atoms/UploadStatusIcon',
+ component: UploadStatusIconComponent,
+ render: (args) => ({
+ props: args,
+ template: ``,
+ }),
+};
+export default meta;
+type Story = StoryObj;
+
+export const Complete: Story = { args: { status: 'complete' } };
+export const Failed: Story = { args: { status: 'failed' } };
+export const Uploading: Story = { args: { status: 'uploading' } };