Upload feature (f): demo scenarios (upload-slow/fail) + a11y (file-input label)

- upload-slow/upload-fail scenarios simulated in the adapter (XHR POST bypasses the
  HTTP interceptor); categories/status/delete already honour the global slow/error
- file-input gets a per-category accessible name (aria-label + visible button text)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 11:42:51 +02:00
parent bfd957a6d4
commit 57940234b2
4 changed files with 52 additions and 4 deletions

View File

@@ -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 {

View File

@@ -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') {
<app-file-input
[inputId]="category().categoryId + '-file'"
[label]="fileInputLabel()"
[accept]="category().acceptedTypes"
[multiple]="category().multiple"
(filesSelected)="fileSelected.emit($event)" />
@@ -67,6 +68,9 @@ export class DocumentCategoryComponent {
channel = input.required<DeliveryChannel>();
rejection = input<string>();
/** Accessible name for the file picker, e.g. "Bestand kiezen voor Diploma". */
protected fileInputLabel = computed(() => $localize`:@@upload.fileInput.labelFor:Bestand kiezen voor ${this.category().label}:category:`);
fileSelected = output<File[]>();
removeUpload = output<string>();
retryUpload = output<string>();

View File

@@ -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()">
<span aria-hidden="true">↑</span>
<span i18n="@@upload.fileInput.label">Bestand kiezen</span>
<span>{{ label() }}</span>
</span>
</span>
`,
@@ -48,6 +49,8 @@ export class FileInputComponent {
multiple = input(false);
disabled = input(false);
inputId = input.required<string>();
/** Accessible name + button text; the domain caller supplies a per-category label. */
label = input($localize`:@@upload.fileInput.label:Bestand kiezen`);
filesSelected = output<File[]>();

View File

@@ -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);