feat(cibg): adopt the vendored CIBG Bestand-upload component

The upload suite now wraps the vendored CIBG file-upload classes instead of
hand-rolling from tokens:
- file-input becomes a .file-picker-drop-area with drag-and-drop + a real
  .btn-upload button and a visually-hidden, focusable <input>; renders an
  always-visible instruction (allowed types + max size) linked to the input via
  aria-describedby (pattern requirement).
- The file list is a semantic ul.file-list; single-upload is an
  li[app-single-upload] .file-container (native <li> child) with .actions
  (retry/.icon-remove) + progress; document-chip renders the .file block
  (status glyph, .file-name link, .file-meta size/status).
- Category validation moves ABOVE the block as .upload-validation > .feedback.
- Instruction text uses the default (not subtle) foreground for WCAG AA contrast
  on the grey drop-area.

DocumentUpload's public inputs/outputs are unchanged — both wizards keep working.
GREEN: lint, tokens, 181 tests, build, 137 axe stories.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 21:47:25 +02:00
parent 98fd7e4bcd
commit b5c5d30a65
8 changed files with 1032 additions and 715 deletions

File diff suppressed because one or more lines are too long

View File

@@ -4,9 +4,10 @@ import { DeliveryChannelToggleComponent } from '../delivery-channel-toggle/deliv
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. */
/** Organism: one document category (CIBG Bestand-upload) — its label/description, an
optional delivery channel toggle, and (when digital) a validation message, the
file picker/drop-zone, and the `ul.file-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],
@@ -15,14 +16,7 @@ import { SingleUploadComponent } from '../single-upload/single-upload.component'
.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);
}
.file-list { list-style: none; padding: 0; margin-block-start: var(--rhc-space-max-md); }
`],
template: `
<div class="label">
@@ -40,27 +34,33 @@ import { SingleUploadComponent } from '../single-upload/single-upload.component'
}
@if (channel() === 'digital') {
<!-- CIBG: validation sits ABOVE the upload block. -->
@if (rejection()) {
<div class="upload-validation">
<div class="feedback feedback-warning" role="alert">{{ rejection() }}</div>
</div>
}
<app-file-input
[inputId]="category().categoryId + '-file'"
[label]="fileInputLabel()"
[accept]="category().acceptedTypes"
[maxSizeMb]="category().maxSizeMb"
[multiple]="category().multiple"
(filesSelected)="fileSelected.emit($event)" />
<div class="uploads">
@if (uploads().length) {
<ul class="file-list">
@for (u of uploads(); track u.localId) {
<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)" />
(retry)="retryUpload.emit(u.localId)"></li>
}
</div>
@if (rejection()) {
<div class="rejection" role="alert">{{ rejection() }}</div>
</ul>
}
}
`,

View File

@@ -1,72 +1,55 @@
import { Component, input, output } from '@angular/core';
import { Component, computed, input } 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. */
const STATUS_LABELS: Record<UploadStatus['type'], string> = {
idle: '',
queued: $localize`:@@upload.status.queued:In wachtrij`,
uploading: $localize`:@@upload.status.uploading:Bezig met uploaden`,
complete: $localize`:@@upload.status.complete:Geüpload`,
failed: $localize`:@@upload.status.failed:Mislukt`,
deleting: $localize`:@@upload.status.deleting:Bezig met verwijderen`,
deleted: '',
};
/** Atom: the `.file` block of a CIBG Bestand-upload file row — a status glyph, the
filename (a download link once complete) and a `.file-meta` line (size + status).
`display:contents` so `.file` becomes a flex child of the `.file-container` row.
Pure UI. */
@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); }
a.action { display: inline-block; }
: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" />
<span class="name">{{ fileName() }}</span>
@if (previewUrl()) {
<a class="action" [href]="previewUrl()" target="_blank" rel="noopener" [attr.aria-label]="previewLabel">
<span i18n="@@upload.chip.preview">Voorbeeld / Download</span>
</a>
}
@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>
<a class="file-name" [href]="previewUrl()" target="_blank" rel="noopener">{{ fileName() }}</a>
} @else {
<span class="file-name">{{ fileName() }}</span>
}
</span>
<span class="file-meta">{{ meta() }}</span>
</div>
`,
})
export class DocumentChipComponent {
fileName = input.required<string>();
status = input.required<UploadStatus>();
/** When set, show a preview/download link (opens the stored bytes). */
fileSizeMb = input(0);
/** When set, the filename is a preview/download link (opens the stored bytes). */
previewUrl = input<string>();
remove = output<void>();
retry = output<void>();
protected get removeLabel(): string {
return $localize`:@@upload.chip.removeAria:${this.fileName()}:fileName: verwijderen`;
}
protected get previewLabel(): string {
return $localize`:@@upload.chip.previewAria:${this.fileName()}:fileName: bekijken of downloaden`;
}
protected get retryLabel(): string {
return $localize`:@@upload.chip.retryAria:${this.fileName()}:fileName: opnieuw uploaden`;
}
/** `.file-meta`: file size + status word (+ the failure reason when failed). */
protected meta = computed(() => {
const s = this.status();
const size = this.fileSizeMb() > 0 ? `${this.fileSizeMb().toFixed(1)} MB` : '';
const label = s.type === 'failed' && s.reason ? s.reason : STATUS_LABELS[s.type];
return [size, label].filter(Boolean).join(' · ');
});
}

View File

@@ -7,20 +7,21 @@ const meta: Meta<DocumentChipComponent> = {
component: DocumentChipComponent,
render: (args) => ({
props: args,
template: `<app-document-chip [fileName]="fileName" [status]="status" [previewUrl]="previewUrl" />`,
// The .file/.file-name/.file-meta styling only applies inside ul.file-list > .file-container.
template: `<ul class="file-list"><li class="file-container"><app-document-chip [fileName]="fileName" [status]="status" [fileSizeMb]="fileSizeMb" [previewUrl]="previewUrl" /></li></ul>`,
}),
};
export default meta;
type Story = StoryObj<DocumentChipComponent>;
export const Complete: Story = {
args: { fileName: 'diploma.pdf', 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', 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', status: { type: 'failed', reason: 'Netwerkfout' } as UploadStatus },
args: { fileName: 'diploma.pdf', fileSizeMb: 1.2, status: { type: 'failed', reason: 'Netwerkfout' } as UploadStatus },
};

View File

@@ -1,62 +1,111 @@
import { Component, ElementRef, input, output, viewChild } from '@angular/core';
import { Component, ElementRef, computed, input, output, signal, 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. */
let nextId = 0;
/** Friendly labels for the MIME types the backend sends in `acceptedTypes`. */
const TYPE_LABELS: Record<string, string> = {
'application/pdf': 'PDF',
'image/jpeg': 'JPG',
'image/jpg': 'JPG',
'image/png': 'PNG',
'application/msword': 'Word',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word',
};
/** Atom: the CIBG Huisstijl **Bestand-upload** control
(designsystem.cibg.nl/componenten/bestand-upload) — a `.file-picker-drop-area`
with an always-visible instruction (allowed types + max size), a real
`.btn-upload` button, and a visually-hidden native `<input type="file">`. Supports
click-to-pick and drag-and-drop; resets its value after each change so re-picking
the same file re-fires. The instruction is linked to the input via
`aria-describedby` (pattern requirement). 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);
}
: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); }
.btn-upload { cursor: pointer; }
.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: `
<span class="field">
<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>
}
<input
#fileInput
class="visually-hidden"
type="file"
[id]="inputId()"
[attr.aria-label]="label()"
[attr.aria-describedby]="instructions() ? instructionsId : null"
[accept]="accept().join(',')"
[multiple]="multiple()"
[disabled]="disabled()"
(change)="onChange($event)" />
<span class="btn btn-outline-primary label" [class.disabled]="disabled()">
<span aria-hidden="true">↑</span>
<span>{{ label() }}</span>
</span>
</span>
<label class="btn btn-outline-primary btn-upload" [class.disabled]="disabled()" [attr.for]="inputId()">
{{ buttonText() }}
</label>
</div>
`,
})
export class FileInputComponent {
accept = input<string[]>([]);
multiple = input(false);
disabled = input(false);
maxSizeMb = input(0);
inputId = input.required<string>();
/** Accessible name + button text; the domain caller supplies a per-category label. */
/** Accessible name for the input; the domain caller supplies a per-category label. */
label = input($localize`:@@upload.fileInput.label:Bestand kiezen`);
filesSelected = output<File[]>();
protected readonly instructionsId = `upload-instructions-${nextId++}`;
protected readonly dragging = signal(false);
private fileInput = viewChild.required<ElementRef<HTMLInputElement>>('fileInput');
protected buttonText = computed(() =>
this.multiple()
? $localize`:@@upload.fileInput.addMany:Bestanden toevoegen`
: $localize`:@@upload.fileInput.addOne:Bestand toevoegen`,
);
/** 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 size = this.maxSizeMb();
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 '';
});
onChange(event: Event) {
const input = event.target as HTMLInputElement;
this.filesSelected.emit(Array.from(input.files ?? []));
this.fileInput().nativeElement.value = '';
}
onDragOver(event: DragEvent) {
if (this.disabled()) return;
event.preventDefault(); // allow drop
this.dragging.set(true);
}
onDragLeave(event: DragEvent) {
event.preventDefault();
this.dragging.set(false);
}
onDrop(event: DragEvent) {
event.preventDefault();
this.dragging.set(false);
if (this.disabled()) return;
const files = Array.from(event.dataTransfer?.files ?? []);
if (files.length) this.filesSelected.emit(files);
}
}

View File

@@ -6,16 +6,20 @@ const meta: Meta<FileInputComponent> = {
component: FileInputComponent,
render: (args) => ({
props: args,
template: `<app-file-input [inputId]="inputId" [accept]="accept" [multiple]="multiple" [disabled]="disabled" />`,
template: `<app-file-input [inputId]="inputId" [accept]="accept" [maxSizeMb]="maxSizeMb" [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 },
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 },
};
export const Disabled: Story = {
args: { inputId: 'diploma-disabled', accept: ['application/pdf'], multiple: false, disabled: true },
args: { inputId: 'diploma-disabled', accept: ['application/pdf'], maxSizeMb: 10, multiple: false, disabled: true },
};

View File

@@ -3,23 +3,36 @@ 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. */
/** Molecule: one row in a CIBG Bestand-upload file list — a `.file-container` `<li>`
with the `.file` block (via document-chip), the `.actions` (retry/remove), and a
progress bar while uploading. Used on an `<li>` so `ul.file-list`'s direct child is
a native `<li>`. Pure UI: emits `remove`/`retry`; the container decides what they mean. */
@Component({
selector: 'app-single-upload',
selector: 'li[app-single-upload]',
host: { class: 'file-container' },
imports: [DocumentChipComponent, UploadProgressBarComponent],
styles: [`
:host { display: flex; flex-direction: column; gap: var(--rhc-space-max-sm); }
: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"
[previewUrl]="previewUrl()"
(remove)="remove.emit()"
(retry)="retry.emit()" />
[fileSizeMb]="upload().fileSizeMb"
[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>
}
@if (upload().status.type !== 'deleting') {
<button type="button" class="btn icon-remove" [attr.aria-label]="removeLabel" (click)="remove.emit()"></button>
}
</div>
@if (progressPct() !== null) {
<app-upload-progress-bar [progressPct]="progressPct()!" />
<app-upload-progress-bar class="upload-progress" [progressPct]="progressPct()!" />
}
`,
})
@@ -41,4 +54,11 @@ export class SingleUploadComponent {
remove = output<void>();
retry = output<void>();
protected get removeLabel(): string {
return $localize`:@@upload.chip.removeAria:${this.upload().fileName}:fileName: verwijderen`;
}
protected get retryLabel(): string {
return $localize`:@@upload.chip.retryAria:${this.upload().fileName}:fileName: opnieuw uploaden`;
}
}

View File

@@ -7,7 +7,7 @@ const meta: Meta<SingleUploadComponent> = {
component: SingleUploadComponent,
render: (args) => ({
props: args,
template: `<app-single-upload [upload]="upload" />`,
template: `<ul class="file-list"><li app-single-upload [upload]="upload"></li></ul>`,
}),
};
export default meta;