diff --git a/src/app/shared/infrastructure/scenario.ts b/src/app/shared/infrastructure/scenario.ts
index 9bcd86b..33e78dc 100644
--- a/src/app/shared/infrastructure/scenario.ts
+++ b/src/app/shared/infrastructure/scenario.ts
@@ -1,6 +1,15 @@
-export type Scenario = 'default' | 'slow' | 'loading' | 'empty' | 'error';
+export type Scenario =
+ | 'default'
+ | 'slow'
+ | 'loading'
+ | 'empty'
+ | 'error'
+ // upload-only (the multipart POST is hand-written XHR, so it bypasses the HTTP
+ // interceptor — these are simulated in upload.adapter.ts instead):
+ | 'upload-slow'
+ | 'upload-fail';
-const VALID: Scenario[] = ['default', 'slow', 'loading', 'empty', 'error'];
+const VALID: Scenario[] = ['default', 'slow', 'loading', 'empty', 'error', 'upload-slow', 'upload-fail'];
/** Reads ?scenario= from the URL so a demo can force each async state. */
export function currentScenario(): Scenario {
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
index 77473e3..56f5520 100644
--- a/src/app/shared/ui/upload/document-category/document-category.component.ts
+++ b/src/app/shared/ui/upload/document-category/document-category.component.ts
@@ -1,4 +1,4 @@
-import { Component, input, output } from '@angular/core';
+import { Component, computed, input, output } from '@angular/core';
import type { DeliveryChannel, DocumentCategory, Upload } from '@shared/upload/upload.machine';
import { DeliveryChannelToggleComponent } from '../delivery-channel-toggle/delivery-channel-toggle.component';
import { FileInputComponent } from '../file-input/file-input.component';
@@ -42,6 +42,7 @@ import { SingleUploadComponent } from '../single-upload/single-upload.component'
@if (channel() === 'digital') {
@@ -67,6 +68,9 @@ export class DocumentCategoryComponent {
channel = input.required();
rejection = input();
+ /** Accessible name for the file picker, e.g. "Bestand kiezen voor Diploma". */
+ protected fileInputLabel = computed(() => $localize`:@@upload.fileInput.labelFor:Bestand kiezen voor ${this.category().label}:category:`);
+
fileSelected = output();
removeUpload = output();
retryUpload = output();
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
index 2e17aef..caebe5e 100644
--- a/src/app/shared/ui/upload/file-input/file-input.component.ts
+++ b/src/app/shared/ui/upload/file-input/file-input.component.ts
@@ -30,6 +30,7 @@ import { Component, ElementRef, input, output, viewChild } from '@angular/core';
#fileInput
type="file"
[id]="inputId()"
+ [attr.aria-label]="label()"
[accept]="accept().join(',')"
[multiple]="multiple()"
[disabled]="disabled()"
@@ -38,7 +39,7 @@ import { Component, ElementRef, input, output, viewChild } from '@angular/core';
class="utrecht-button rhc-button utrecht-button--secondary-action label"
[class.utrecht-button--disabled]="disabled()">
↑
- Bestand kiezen
+ {{ label() }}
`,
@@ -48,6 +49,8 @@ export class FileInputComponent {
multiple = input(false);
disabled = input(false);
inputId = input.required();
+ /** Accessible name + button text; the domain caller supplies a per-category label. */
+ label = input($localize`:@@upload.fileInput.label:Bestand kiezen`);
filesSelected = output();
diff --git a/src/app/shared/upload/upload.adapter.ts b/src/app/shared/upload/upload.adapter.ts
index ef7ef85..9946ceb 100644
--- a/src/app/shared/upload/upload.adapter.ts
+++ b/src/app/shared/upload/upload.adapter.ts
@@ -5,6 +5,7 @@ import {
UploadStatusItemDto,
} from '@shared/infrastructure/api-client';
import { problemDetail } from '@shared/infrastructure/api-error';
+import { currentScenario } from '@shared/infrastructure/scenario';
import { environment } from '../../../environments/environment';
import { DocumentCategory } from './upload.machine';
@@ -63,6 +64,9 @@ export class UploadAdapter {
* transport behind UploadTransport for true background sync.
*/
xhrUpload(req: XhrUploadRequest, onProgress: (pct: number) => void): XhrUploadHandle {
+ const scenario = currentScenario();
+ if (scenario === 'upload-slow' || scenario === 'upload-fail') return simulateUpload(scenario, onProgress);
+
const xhr = new XMLHttpRequest();
const form = new FormData();
form.append('file', req.file);
@@ -98,6 +102,34 @@ export class UploadAdapter {
const UPLOAD_FAILED = $localize`:@@upload.failed:Uploaden is niet gelukt. Probeer het opnieuw.`;
const genericError = (): string => UPLOAD_FAILED;
+
+/**
+ * Demo-only (dev): the real XHR POST finishes instantly for metadata, so progress
+ * and failure can't otherwise be shown. Drives the progress bar over ~2.5s, then
+ * succeeds (`upload-slow`) or fails (`upload-fail`). ponytail: timer-based, cancel
+ * via the returned handle; no network.
+ */
+function simulateUpload(scenario: 'upload-slow' | 'upload-fail', onProgress: (pct: number) => void): XhrUploadHandle {
+ let pct = 0;
+ let cancelled = false;
+ const done = new Promise<{ documentId: string }>((resolve, reject) => {
+ const tick = () => {
+ if (cancelled) {
+ reject(UPLOAD_ABORTED);
+ } else if (pct < 100) {
+ pct += 10;
+ onProgress(Math.min(pct, 100));
+ setTimeout(tick, 250);
+ } else if (scenario === 'upload-fail') {
+ reject(UPLOAD_FAILED);
+ } else {
+ resolve({ documentId: `demo-${crypto.randomUUID()}` });
+ }
+ };
+ setTimeout(tick, 250);
+ });
+ return { done, cancel: () => void (cancelled = true) };
+}
const parseError = (body: string): string => {
try {
return problemDetail(JSON.parse(body), UPLOAD_FAILED);