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:
1442
documentation.json
1442
documentation.json
File diff suppressed because one or more lines are too long
@@ -4,9 +4,10 @@ import { DeliveryChannelToggleComponent } from '../delivery-channel-toggle/deliv
|
|||||||
import { FileInputComponent } from '../file-input/file-input.component';
|
import { FileInputComponent } from '../file-input/file-input.component';
|
||||||
import { SingleUploadComponent } from '../single-upload/single-upload.component';
|
import { SingleUploadComponent } from '../single-upload/single-upload.component';
|
||||||
|
|
||||||
/** Organism: one document category — its label/description, an optional delivery
|
/** Organism: one document category (CIBG Bestand-upload) — its label/description, an
|
||||||
channel toggle, and (when digital) a file picker plus the list of uploads. Pure
|
optional delivery channel toggle, and (when digital) a validation message, the
|
||||||
UI: emits selection/removal/retry/delete and channel changes; no HTTP or rules. */
|
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({
|
@Component({
|
||||||
selector: 'app-document-category',
|
selector: 'app-document-category',
|
||||||
imports: [DeliveryChannelToggleComponent, FileInputComponent, SingleUploadComponent],
|
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); }
|
.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); }
|
.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); }
|
.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); }
|
.file-list { list-style: none; padding: 0; 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: `
|
template: `
|
||||||
<div class="label">
|
<div class="label">
|
||||||
@@ -40,27 +34,33 @@ import { SingleUploadComponent } from '../single-upload/single-upload.component'
|
|||||||
}
|
}
|
||||||
|
|
||||||
@if (channel() === 'digital') {
|
@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
|
<app-file-input
|
||||||
[inputId]="category().categoryId + '-file'"
|
[inputId]="category().categoryId + '-file'"
|
||||||
[label]="fileInputLabel()"
|
[label]="fileInputLabel()"
|
||||||
[accept]="category().acceptedTypes"
|
[accept]="category().acceptedTypes"
|
||||||
|
[maxSizeMb]="category().maxSizeMb"
|
||||||
[multiple]="category().multiple"
|
[multiple]="category().multiple"
|
||||||
(filesSelected)="fileSelected.emit($event)" />
|
(filesSelected)="fileSelected.emit($event)" />
|
||||||
|
|
||||||
<div class="uploads">
|
@if (uploads().length) {
|
||||||
|
<ul class="file-list">
|
||||||
@for (u of uploads(); track u.localId) {
|
@for (u of uploads(); track u.localId) {
|
||||||
<app-single-upload
|
<li app-single-upload
|
||||||
animate.enter="app-item-enter"
|
animate.enter="app-item-enter"
|
||||||
animate.leave="app-item-leave"
|
animate.leave="app-item-leave"
|
||||||
[upload]="u"
|
[upload]="u"
|
||||||
[previewUrlFor]="previewUrlFor()"
|
[previewUrlFor]="previewUrlFor()"
|
||||||
(remove)="onRemove(u)"
|
(remove)="onRemove(u)"
|
||||||
(retry)="retryUpload.emit(u.localId)" />
|
(retry)="retryUpload.emit(u.localId)"></li>
|
||||||
}
|
}
|
||||||
</div>
|
</ul>
|
||||||
|
|
||||||
@if (rejection()) {
|
|
||||||
<div class="rejection" role="alert">{{ rejection() }}</div>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -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 type { UploadStatus } from '@shared/upload/upload.machine';
|
||||||
import { UploadStatusIconComponent } from '../upload-status-icon/upload-status-icon.component';
|
import { UploadStatusIconComponent } from '../upload-status-icon/upload-status-icon.component';
|
||||||
|
|
||||||
/** Atom: a single uploaded-file chip — filename + status glyph + actions. Pure UI:
|
const STATUS_LABELS: Record<UploadStatus['type'], string> = {
|
||||||
emits `remove`/`retry`; the container decides what they mean. */
|
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({
|
@Component({
|
||||||
selector: 'app-document-chip',
|
selector: 'app-document-chip',
|
||||||
imports: [UploadStatusIconComponent],
|
imports: [UploadStatusIconComponent],
|
||||||
styles: [`
|
styles: [`
|
||||||
:host {
|
:host { display: contents; }
|
||||||
display: inline-flex;
|
.file { display: flex; flex-direction: column; gap: var(--rhc-space-max-xs); min-inline-size: 0; }
|
||||||
align-items: center;
|
.file-name { word-break: break-all; }
|
||||||
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; }
|
|
||||||
`],
|
`],
|
||||||
template: `
|
template: `
|
||||||
|
<div class="file">
|
||||||
|
<span class="d-inline-flex align-items-center gap-2">
|
||||||
<app-upload-status-icon [status]="status().type" />
|
<app-upload-status-icon [status]="status().type" />
|
||||||
<span class="name">{{ fileName() }}</span>
|
|
||||||
@if (previewUrl()) {
|
@if (previewUrl()) {
|
||||||
<a class="action" [href]="previewUrl()" target="_blank" rel="noopener" [attr.aria-label]="previewLabel">
|
<a class="file-name" [href]="previewUrl()" target="_blank" rel="noopener">{{ fileName() }}</a>
|
||||||
<span i18n="@@upload.chip.preview">Voorbeeld / Download</span>
|
} @else {
|
||||||
</a>
|
<span class="file-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>
|
|
||||||
}
|
}
|
||||||
|
</span>
|
||||||
|
<span class="file-meta">{{ meta() }}</span>
|
||||||
|
</div>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class DocumentChipComponent {
|
export class DocumentChipComponent {
|
||||||
fileName = input.required<string>();
|
fileName = input.required<string>();
|
||||||
status = input.required<UploadStatus>();
|
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>();
|
previewUrl = input<string>();
|
||||||
|
|
||||||
remove = output<void>();
|
/** `.file-meta`: file size + status word (+ the failure reason when failed). */
|
||||||
retry = output<void>();
|
protected meta = computed(() => {
|
||||||
|
const s = this.status();
|
||||||
protected get removeLabel(): string {
|
const size = this.fileSizeMb() > 0 ? `${this.fileSizeMb().toFixed(1)} MB` : '';
|
||||||
return $localize`:@@upload.chip.removeAria:${this.fileName()}:fileName: verwijderen`;
|
const label = s.type === 'failed' && s.reason ? s.reason : STATUS_LABELS[s.type];
|
||||||
}
|
return [size, label].filter(Boolean).join(' · ');
|
||||||
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`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,20 +7,21 @@ const meta: Meta<DocumentChipComponent> = {
|
|||||||
component: DocumentChipComponent,
|
component: DocumentChipComponent,
|
||||||
render: (args) => ({
|
render: (args) => ({
|
||||||
props: 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;
|
export default meta;
|
||||||
type Story = StoryObj<DocumentChipComponent>;
|
type Story = StoryObj<DocumentChipComponent>;
|
||||||
|
|
||||||
export const Complete: Story = {
|
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 = {
|
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 = {
|
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 },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
let nextId = 0;
|
||||||
selected files; resets its value after each change so re-picking the same file
|
|
||||||
re-fires. Pure UI — no validation, no upload. */
|
/** 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({
|
@Component({
|
||||||
selector: 'app-file-input',
|
selector: 'app-file-input',
|
||||||
styles: [`
|
styles: [`
|
||||||
:host { display: inline-block; }
|
:host { display: block; }
|
||||||
.field { position: relative; display: inline-flex; }
|
.file-picker-drop-area { display: flex; flex-direction: column; align-items: flex-start; gap: var(--rhc-space-max-sm); }
|
||||||
input[type='file'] {
|
/* Default (not subtle) foreground: subtle grey on the grey drop-area fails WCAG AA contrast. */
|
||||||
position: absolute;
|
.upload-instructions { margin: 0; color: var(--rhc-color-foreground-default); font-size: var(--rhc-text-font-size-sm); }
|
||||||
inset: 0;
|
.btn-upload { cursor: pointer; }
|
||||||
width: 100%;
|
.btn-upload.disabled { cursor: not-allowed; }
|
||||||
height: 100%;
|
/* The input is visually hidden but still focusable; surface its focus on the button. */
|
||||||
opacity: 0;
|
input:focus-visible + .btn-upload { outline: var(--rhc-border-width-md) solid var(--rhc-color-foreground-link); outline-offset: 2px; }
|
||||||
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: `
|
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
|
<input
|
||||||
#fileInput
|
#fileInput
|
||||||
|
class="visually-hidden"
|
||||||
type="file"
|
type="file"
|
||||||
[id]="inputId()"
|
[id]="inputId()"
|
||||||
[attr.aria-label]="label()"
|
[attr.aria-label]="label()"
|
||||||
|
[attr.aria-describedby]="instructions() ? instructionsId : null"
|
||||||
[accept]="accept().join(',')"
|
[accept]="accept().join(',')"
|
||||||
[multiple]="multiple()"
|
[multiple]="multiple()"
|
||||||
[disabled]="disabled()"
|
[disabled]="disabled()"
|
||||||
(change)="onChange($event)" />
|
(change)="onChange($event)" />
|
||||||
<span class="btn btn-outline-primary label" [class.disabled]="disabled()">
|
<label class="btn btn-outline-primary btn-upload" [class.disabled]="disabled()" [attr.for]="inputId()">
|
||||||
<span aria-hidden="true">↑</span>
|
{{ buttonText() }}
|
||||||
<span>{{ label() }}</span>
|
</label>
|
||||||
</span>
|
</div>
|
||||||
</span>
|
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class FileInputComponent {
|
export class FileInputComponent {
|
||||||
accept = input<string[]>([]);
|
accept = input<string[]>([]);
|
||||||
multiple = input(false);
|
multiple = input(false);
|
||||||
disabled = input(false);
|
disabled = input(false);
|
||||||
|
maxSizeMb = input(0);
|
||||||
inputId = input.required<string>();
|
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`);
|
label = input($localize`:@@upload.fileInput.label:Bestand kiezen`);
|
||||||
|
|
||||||
filesSelected = output<File[]>();
|
filesSelected = output<File[]>();
|
||||||
|
|
||||||
|
protected readonly instructionsId = `upload-instructions-${nextId++}`;
|
||||||
|
protected readonly dragging = signal(false);
|
||||||
|
|
||||||
private fileInput = viewChild.required<ElementRef<HTMLInputElement>>('fileInput');
|
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) {
|
onChange(event: Event) {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
this.filesSelected.emit(Array.from(input.files ?? []));
|
this.filesSelected.emit(Array.from(input.files ?? []));
|
||||||
this.fileInput().nativeElement.value = '';
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +6,20 @@ const meta: Meta<FileInputComponent> = {
|
|||||||
component: FileInputComponent,
|
component: FileInputComponent,
|
||||||
render: (args) => ({
|
render: (args) => ({
|
||||||
props: 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;
|
export default meta;
|
||||||
type Story = StoryObj<FileInputComponent>;
|
type Story = StoryObj<FileInputComponent>;
|
||||||
|
|
||||||
export const Default: Story = {
|
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 = {
|
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 },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,23 +3,36 @@ import type { Upload } from '@shared/upload/upload.machine';
|
|||||||
import { DocumentChipComponent } from '../document-chip/document-chip.component';
|
import { DocumentChipComponent } from '../document-chip/document-chip.component';
|
||||||
import { UploadProgressBarComponent } from '../upload-progress-bar/upload-progress-bar.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:
|
/** Molecule: one row in a CIBG Bestand-upload file list — a `.file-container` `<li>`
|
||||||
forwards `remove`/`retry` from the chip. */
|
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({
|
@Component({
|
||||||
selector: 'app-single-upload',
|
selector: 'li[app-single-upload]',
|
||||||
|
host: { class: 'file-container' },
|
||||||
imports: [DocumentChipComponent, UploadProgressBarComponent],
|
imports: [DocumentChipComponent, UploadProgressBarComponent],
|
||||||
styles: [`
|
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: `
|
template: `
|
||||||
<app-document-chip
|
<app-document-chip
|
||||||
[fileName]="upload().fileName"
|
[fileName]="upload().fileName"
|
||||||
[status]="upload().status"
|
[status]="upload().status"
|
||||||
[previewUrl]="previewUrl()"
|
[fileSizeMb]="upload().fileSizeMb"
|
||||||
(remove)="remove.emit()"
|
[previewUrl]="previewUrl()" />
|
||||||
(retry)="retry.emit()" />
|
<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) {
|
@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>();
|
remove = output<void>();
|
||||||
retry = 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`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const meta: Meta<SingleUploadComponent> = {
|
|||||||
component: SingleUploadComponent,
|
component: SingleUploadComponent,
|
||||||
render: (args) => ({
|
render: (args) => ({
|
||||||
props: 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;
|
export default meta;
|
||||||
|
|||||||
Reference in New Issue
Block a user