Upload feature (c): atomic UI components (atoms/molecules/organisms) + stories
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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: `
|
||||
<div class="utrecht-form-field-radio-group" role="radiogroup">
|
||||
@for (opt of options; track opt.value) {
|
||||
<label class="utrecht-form-label utrecht-form-label--radio-button rhc-radio-option">
|
||||
<input
|
||||
class="utrecht-radio-button"
|
||||
[class.utrecht-radio-button--checked]="channel() === opt.value"
|
||||
type="radio"
|
||||
[name]="name()"
|
||||
[value]="opt.value"
|
||||
[checked]="channel() === opt.value"
|
||||
[disabled]="disabled()"
|
||||
(change)="channelChange.emit(opt.value)" />
|
||||
{{ opt.label }}
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class DeliveryChannelToggleComponent {
|
||||
channel = input.required<DeliveryChannel>();
|
||||
name = input.required<string>();
|
||||
disabled = input(false);
|
||||
|
||||
channelChange = output<DeliveryChannel>();
|
||||
|
||||
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` },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { DeliveryChannelToggleComponent } from './delivery-channel-toggle.component';
|
||||
|
||||
const meta: Meta<DeliveryChannelToggleComponent> = {
|
||||
title: 'Atoms/DeliveryChannelToggle',
|
||||
component: DeliveryChannelToggleComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-delivery-channel-toggle [channel]="channel" [name]="name" [disabled]="disabled" />`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<DeliveryChannelToggleComponent>;
|
||||
|
||||
export const Digital: Story = { args: { channel: 'digital', name: 'diploma-channel', disabled: false } };
|
||||
export const Post: Story = { args: { channel: 'post', name: 'diploma-channel', disabled: false } };
|
||||
@@ -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: `
|
||||
<div class="label">
|
||||
{{ category().label }}@if (category().required) { <span class="req" i18n="@@upload.category.required">(verplicht)</span> }
|
||||
</div>
|
||||
@if (category().description) {
|
||||
<div class="desc">{{ category().description }}</div>
|
||||
}
|
||||
|
||||
@if (category().allowPostDelivery) {
|
||||
<app-delivery-channel-toggle
|
||||
[channel]="channel()"
|
||||
[name]="category().categoryId + '-channel'"
|
||||
(channelChange)="channelChange.emit($event)" />
|
||||
}
|
||||
|
||||
@if (channel() === 'digital') {
|
||||
<app-file-input
|
||||
[inputId]="category().categoryId + '-file'"
|
||||
[accept]="category().acceptedTypes"
|
||||
[multiple]="category().multiple"
|
||||
(filesSelected)="fileSelected.emit($event)" />
|
||||
|
||||
<div class="uploads">
|
||||
@for (u of uploads(); track u.localId) {
|
||||
<app-single-upload
|
||||
[upload]="u"
|
||||
(remove)="onRemove(u)"
|
||||
(retry)="retryUpload.emit(u.localId)" />
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (rejection()) {
|
||||
<div class="rejection" role="alert">{{ rejection() }}</div>
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class DocumentCategoryComponent {
|
||||
category = input.required<DocumentCategory>();
|
||||
uploads = input.required<Upload[]>();
|
||||
channel = input.required<DeliveryChannel>();
|
||||
rejection = input<string>();
|
||||
|
||||
fileSelected = output<File[]>();
|
||||
removeUpload = output<string>();
|
||||
retryUpload = output<string>();
|
||||
deleteUpload = output<{ localId: string; documentId: string }>();
|
||||
channelChange = output<DeliveryChannel>();
|
||||
|
||||
onRemove(u: Upload) {
|
||||
if (u.status.type === 'complete') {
|
||||
this.deleteUpload.emit({ localId: u.localId, documentId: u.status.documentId });
|
||||
} else {
|
||||
this.removeUpload.emit(u.localId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<DocumentCategoryComponent> = {
|
||||
title: 'Organisms/DocumentCategory',
|
||||
component: DocumentCategoryComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-document-category [category]="category" [uploads]="uploads" [channel]="channel" [rejection]="rejection" />`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<DocumentCategoryComponent>;
|
||||
|
||||
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.' },
|
||||
};
|
||||
@@ -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: `
|
||||
<app-upload-status-icon [status]="status().type" />
|
||||
<span class="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>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class DocumentChipComponent {
|
||||
fileName = input.required<string>();
|
||||
status = input.required<UploadStatus>();
|
||||
|
||||
remove = output<void>();
|
||||
retry = output<void>();
|
||||
|
||||
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`;
|
||||
}
|
||||
}
|
||||
@@ -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<DocumentChipComponent> = {
|
||||
title: 'Atoms/DocumentChip',
|
||||
component: DocumentChipComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-document-chip [fileName]="fileName" [status]="status" />`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<DocumentChipComponent>;
|
||||
|
||||
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 },
|
||||
};
|
||||
@@ -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) {
|
||||
<app-upload-status-banner type="error" [message]="state().categoriesError!" />
|
||||
} @else {
|
||||
@if (state().backgroundSyncAvailable === false) {
|
||||
<app-upload-status-banner type="info" [message]="foregroundOnlyMessage" />
|
||||
}
|
||||
@for (c of state().categories; track c.categoryId) {
|
||||
<app-document-category
|
||||
[category]="c"
|
||||
[uploads]="uploadsFor(c.categoryId)"
|
||||
[channel]="channelFor(c.categoryId)"
|
||||
[rejection]="state().rejections[c.categoryId]"
|
||||
(fileSelected)="fileSelected.emit({ categoryId: c.categoryId, files: $event })"
|
||||
(removeUpload)="removeUpload.emit($event)"
|
||||
(retryUpload)="retryUpload.emit($event)"
|
||||
(deleteUpload)="deleteUpload.emit($event)"
|
||||
(channelChange)="channelChange.emit({ categoryId: c.categoryId, channel: $event })" />
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class DocumentUploadComponent {
|
||||
state = input.required<UploadState>();
|
||||
|
||||
fileSelected = output<{ categoryId: string; files: File[] }>();
|
||||
removeUpload = output<string>();
|
||||
retryUpload = output<string>();
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -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<DocumentUploadComponent> = {
|
||||
title: 'Organisms/DocumentUpload',
|
||||
component: DocumentUploadComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-document-upload [state]="state" />`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<DocumentUploadComponent>;
|
||||
|
||||
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.' } },
|
||||
};
|
||||
61
src/app/shared/ui/upload/file-input/file-input.component.ts
Normal file
61
src/app/shared/ui/upload/file-input/file-input.component.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Component, ElementRef, input, output, 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. */
|
||||
@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: `
|
||||
<span class="field">
|
||||
<input
|
||||
#fileInput
|
||||
type="file"
|
||||
[id]="inputId()"
|
||||
[accept]="accept().join(',')"
|
||||
[multiple]="multiple()"
|
||||
[disabled]="disabled()"
|
||||
(change)="onChange($event)" />
|
||||
<span
|
||||
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>
|
||||
</span>
|
||||
`,
|
||||
})
|
||||
export class FileInputComponent {
|
||||
accept = input<string[]>([]);
|
||||
multiple = input(false);
|
||||
disabled = input(false);
|
||||
inputId = input.required<string>();
|
||||
|
||||
filesSelected = output<File[]>();
|
||||
|
||||
private fileInput = viewChild.required<ElementRef<HTMLInputElement>>('fileInput');
|
||||
|
||||
onChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.filesSelected.emit(Array.from(input.files ?? []));
|
||||
this.fileInput().nativeElement.value = '';
|
||||
}
|
||||
}
|
||||
21
src/app/shared/ui/upload/file-input/file-input.stories.ts
Normal file
21
src/app/shared/ui/upload/file-input/file-input.stories.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { FileInputComponent } from './file-input.component';
|
||||
|
||||
const meta: Meta<FileInputComponent> = {
|
||||
title: 'Atoms/FileInput',
|
||||
component: FileInputComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-file-input [inputId]="inputId" [accept]="accept" [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 },
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { inputId: 'diploma-disabled', accept: ['application/pdf'], multiple: false, disabled: true },
|
||||
};
|
||||
@@ -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: `
|
||||
<app-document-chip
|
||||
[fileName]="upload().fileName"
|
||||
[status]="upload().status"
|
||||
(remove)="remove.emit()"
|
||||
(retry)="retry.emit()" />
|
||||
@if (progressPct() !== null) {
|
||||
<app-upload-progress-bar [progressPct]="progressPct()!" />
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class SingleUploadComponent {
|
||||
upload = input.required<Upload>();
|
||||
|
||||
/** Narrow the status union once, in TS, so the template stays type-safe. */
|
||||
protected readonly progressPct = computed<number | null>(() => {
|
||||
const status = this.upload().status;
|
||||
return status.type === 'uploading' ? status.progressPct : null;
|
||||
});
|
||||
|
||||
remove = output<void>();
|
||||
retry = output<void>();
|
||||
}
|
||||
@@ -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<SingleUploadComponent> = {
|
||||
title: 'Molecules/SingleUpload',
|
||||
component: SingleUploadComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-single-upload [upload]="upload" />`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<SingleUploadComponent>;
|
||||
|
||||
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 } } },
|
||||
};
|
||||
@@ -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: `
|
||||
<progress
|
||||
max="100"
|
||||
[value]="progressPct()"
|
||||
[attr.aria-label]="progressLabel"></progress>
|
||||
<span class="pct">{{ progressPct() }}%</span>
|
||||
`,
|
||||
})
|
||||
export class UploadProgressBarComponent {
|
||||
progressPct = input.required<number>();
|
||||
|
||||
protected readonly progressLabel = $localize`:@@upload.progress.label:Uploadvoortgang`;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { UploadProgressBarComponent } from './upload-progress-bar.component';
|
||||
|
||||
const meta: Meta<UploadProgressBarComponent> = {
|
||||
title: 'Atoms/UploadProgressBar',
|
||||
component: UploadProgressBarComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-upload-progress-bar [progressPct]="progressPct" />`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<UploadProgressBarComponent>;
|
||||
|
||||
export const Halfway: Story = { args: { progressPct: 45 } };
|
||||
export const Almost: Story = { args: { progressPct: 92 } };
|
||||
@@ -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: `
|
||||
<div aria-live="polite">
|
||||
<app-alert [type]="alertType()">{{ message() }}</app-alert>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class UploadStatusBannerComponent {
|
||||
message = input.required<string>();
|
||||
type = input<BannerType>('info');
|
||||
|
||||
protected readonly alertType = computed<AlertType>(() => (this.type() === 'info' ? 'info' : this.type()));
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { UploadStatusBannerComponent } from './upload-status-banner.component';
|
||||
|
||||
const meta: Meta<UploadStatusBannerComponent> = {
|
||||
title: 'Molecules/UploadStatusBanner',
|
||||
component: UploadStatusBannerComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-upload-status-banner [message]="message" [type]="type" />`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<UploadStatusBannerComponent>;
|
||||
|
||||
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.' },
|
||||
};
|
||||
@@ -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()) {
|
||||
<span class="glyph" [style.color]="glyph()!.color" [attr.aria-label]="glyph()!.label" role="img">
|
||||
{{ glyph()!.char }}
|
||||
</span>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class UploadStatusIconComponent {
|
||||
status = input.required<UploadStatus['type']>();
|
||||
|
||||
protected readonly glyph = computed<Glyph | null>(() => {
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { UploadStatusIconComponent } from './upload-status-icon.component';
|
||||
|
||||
const meta: Meta<UploadStatusIconComponent> = {
|
||||
title: 'Atoms/UploadStatusIcon',
|
||||
component: UploadStatusIconComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-upload-status-icon [status]="status" />`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<UploadStatusIconComponent>;
|
||||
|
||||
export const Complete: Story = { args: { status: 'complete' } };
|
||||
export const Failed: Story = { args: { status: 'failed' } };
|
||||
export const Uploading: Story = { args: { status: 'uploading' } };
|
||||
Reference in New Issue
Block a user