From 9521739ac164f17025b4799e3c79a06f857bf74a Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Tue, 30 Jun 2026 06:25:30 +0200 Subject: [PATCH] Upload feature (c): atomic UI components (atoms/molecules/organisms) + stories Co-Authored-By: Claude Opus 4.8 --- .../delivery-channel-toggle.component.ts | 46 ++++++++++ .../delivery-channel-toggle.stories.ts | 16 ++++ .../document-category.component.ts | 83 +++++++++++++++++++ .../document-category.stories.ts | 44 ++++++++++ .../document-chip/document-chip.component.ts | 61 ++++++++++++++ .../document-chip/document-chip.stories.ts | 22 +++++ .../document-upload.component.ts | 54 ++++++++++++ .../document-upload.stories.ts | 58 +++++++++++++ .../upload/file-input/file-input.component.ts | 61 ++++++++++++++ .../upload/file-input/file-input.stories.ts | 21 +++++ .../single-upload/single-upload.component.ts | 36 ++++++++ .../single-upload/single-upload.stories.ts | 29 +++++++ .../upload-progress-bar.component.ts | 24 ++++++ .../upload-progress-bar.stories.ts | 16 ++++ .../upload-status-banner.component.ts | 23 +++++ .../upload-status-banner.stories.ts | 21 +++++ .../upload-status-icon.component.ts | 45 ++++++++++ .../upload-status-icon.stories.ts | 17 ++++ 18 files changed, 677 insertions(+) create mode 100644 src/app/shared/ui/upload/delivery-channel-toggle/delivery-channel-toggle.component.ts create mode 100644 src/app/shared/ui/upload/delivery-channel-toggle/delivery-channel-toggle.stories.ts create mode 100644 src/app/shared/ui/upload/document-category/document-category.component.ts create mode 100644 src/app/shared/ui/upload/document-category/document-category.stories.ts create mode 100644 src/app/shared/ui/upload/document-chip/document-chip.component.ts create mode 100644 src/app/shared/ui/upload/document-chip/document-chip.stories.ts create mode 100644 src/app/shared/ui/upload/document-upload/document-upload.component.ts create mode 100644 src/app/shared/ui/upload/document-upload/document-upload.stories.ts create mode 100644 src/app/shared/ui/upload/file-input/file-input.component.ts create mode 100644 src/app/shared/ui/upload/file-input/file-input.stories.ts create mode 100644 src/app/shared/ui/upload/single-upload/single-upload.component.ts create mode 100644 src/app/shared/ui/upload/single-upload/single-upload.stories.ts create mode 100644 src/app/shared/ui/upload/upload-progress-bar/upload-progress-bar.component.ts create mode 100644 src/app/shared/ui/upload/upload-progress-bar/upload-progress-bar.stories.ts create mode 100644 src/app/shared/ui/upload/upload-status-banner/upload-status-banner.component.ts create mode 100644 src/app/shared/ui/upload/upload-status-banner/upload-status-banner.stories.ts create mode 100644 src/app/shared/ui/upload/upload-status-icon/upload-status-icon.component.ts create mode 100644 src/app/shared/ui/upload/upload-status-icon/upload-status-icon.stories.ts diff --git a/src/app/shared/ui/upload/delivery-channel-toggle/delivery-channel-toggle.component.ts b/src/app/shared/ui/upload/delivery-channel-toggle/delivery-channel-toggle.component.ts new file mode 100644 index 0000000..d62657f --- /dev/null +++ b/src/app/shared/ui/upload/delivery-channel-toggle/delivery-channel-toggle.component.ts @@ -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: ` +
+ @for (opt of options; track opt.value) { + + } +
+ `, +}) +export class DeliveryChannelToggleComponent { + channel = input.required(); + name = input.required(); + disabled = input(false); + + channelChange = output(); + + 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` }, + ]; +} diff --git a/src/app/shared/ui/upload/delivery-channel-toggle/delivery-channel-toggle.stories.ts b/src/app/shared/ui/upload/delivery-channel-toggle/delivery-channel-toggle.stories.ts new file mode 100644 index 0000000..940872d --- /dev/null +++ b/src/app/shared/ui/upload/delivery-channel-toggle/delivery-channel-toggle.stories.ts @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { DeliveryChannelToggleComponent } from './delivery-channel-toggle.component'; + +const meta: Meta = { + title: 'Atoms/DeliveryChannelToggle', + component: DeliveryChannelToggleComponent, + render: (args) => ({ + props: args, + template: ``, + }), +}; +export default meta; +type Story = StoryObj; + +export const Digital: Story = { args: { channel: 'digital', name: 'diploma-channel', disabled: false } }; +export const Post: Story = { args: { channel: 'post', name: 'diploma-channel', disabled: false } }; 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 new file mode 100644 index 0000000..77473e3 --- /dev/null +++ b/src/app/shared/ui/upload/document-category/document-category.component.ts @@ -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: ` +
+ {{ category().label }}@if (category().required) { (verplicht) } +
+ @if (category().description) { +
{{ category().description }}
+ } + + @if (category().allowPostDelivery) { + + } + + @if (channel() === 'digital') { + + +
+ @for (u of uploads(); track u.localId) { + + } +
+ + @if (rejection()) { + + } + } + `, +}) +export class DocumentCategoryComponent { + category = input.required(); + uploads = input.required(); + channel = input.required(); + rejection = input(); + + fileSelected = output(); + removeUpload = output(); + retryUpload = output(); + deleteUpload = output<{ localId: string; documentId: string }>(); + channelChange = output(); + + onRemove(u: Upload) { + if (u.status.type === 'complete') { + this.deleteUpload.emit({ localId: u.localId, documentId: u.status.documentId }); + } else { + this.removeUpload.emit(u.localId); + } + } +} diff --git a/src/app/shared/ui/upload/document-category/document-category.stories.ts b/src/app/shared/ui/upload/document-category/document-category.stories.ts new file mode 100644 index 0000000..8fcdd0c --- /dev/null +++ b/src/app/shared/ui/upload/document-category/document-category.stories.ts @@ -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 = { + title: 'Organisms/DocumentCategory', + component: DocumentCategoryComponent, + render: (args) => ({ + props: args, + template: ``, + }), +}; +export default meta; +type Story = StoryObj; + +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.' }, +}; diff --git a/src/app/shared/ui/upload/document-chip/document-chip.component.ts b/src/app/shared/ui/upload/document-chip/document-chip.component.ts new file mode 100644 index 0000000..72d866a --- /dev/null +++ b/src/app/shared/ui/upload/document-chip/document-chip.component.ts @@ -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: ` + + {{ fileName() }} + @if (status().type === 'failed') { + + } + @if (status().type !== 'deleting') { + + } + `, +}) +export class DocumentChipComponent { + fileName = input.required(); + status = input.required(); + + remove = output(); + retry = output(); + + 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`; + } +} diff --git a/src/app/shared/ui/upload/document-chip/document-chip.stories.ts b/src/app/shared/ui/upload/document-chip/document-chip.stories.ts new file mode 100644 index 0000000..f9a681c --- /dev/null +++ b/src/app/shared/ui/upload/document-chip/document-chip.stories.ts @@ -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 = { + title: 'Atoms/DocumentChip', + component: DocumentChipComponent, + render: (args) => ({ + props: args, + template: ``, + }), +}; +export default meta; +type Story = StoryObj; + +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 }, +}; diff --git a/src/app/shared/ui/upload/document-upload/document-upload.component.ts b/src/app/shared/ui/upload/document-upload/document-upload.component.ts new file mode 100644 index 0000000..0dbd3ec --- /dev/null +++ b/src/app/shared/ui/upload/document-upload/document-upload.component.ts @@ -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) { + + } @else { + @if (state().backgroundSyncAvailable === false) { + + } + @for (c of state().categories; track c.categoryId) { + + } + } + `, +}) +export class DocumentUploadComponent { + state = input.required(); + + fileSelected = output<{ categoryId: string; files: File[] }>(); + removeUpload = output(); + retryUpload = output(); + 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'; + } +} diff --git a/src/app/shared/ui/upload/document-upload/document-upload.stories.ts b/src/app/shared/ui/upload/document-upload/document-upload.stories.ts new file mode 100644 index 0000000..b50c203 --- /dev/null +++ b/src/app/shared/ui/upload/document-upload/document-upload.stories.ts @@ -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 = { + title: 'Organisms/DocumentUpload', + component: DocumentUploadComponent, + render: (args) => ({ + props: args, + template: ``, + }), +}; +export default meta; +type Story = StoryObj; + +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.' } }, +}; 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 new file mode 100644 index 0000000..2e17aef --- /dev/null +++ b/src/app/shared/ui/upload/file-input/file-input.component.ts @@ -0,0 +1,61 @@ +import { Component, ElementRef, input, output, viewChild } from '@angular/core'; + +/** Atom: a styled file picker. Wraps a native `` 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: ` + + + + + Bestand kiezen + + + `, +}) +export class FileInputComponent { + accept = input([]); + multiple = input(false); + disabled = input(false); + inputId = input.required(); + + filesSelected = output(); + + private fileInput = viewChild.required>('fileInput'); + + onChange(event: Event) { + const input = event.target as HTMLInputElement; + this.filesSelected.emit(Array.from(input.files ?? [])); + this.fileInput().nativeElement.value = ''; + } +} diff --git a/src/app/shared/ui/upload/file-input/file-input.stories.ts b/src/app/shared/ui/upload/file-input/file-input.stories.ts new file mode 100644 index 0000000..b6cabf4 --- /dev/null +++ b/src/app/shared/ui/upload/file-input/file-input.stories.ts @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { FileInputComponent } from './file-input.component'; + +const meta: Meta = { + title: 'Atoms/FileInput', + component: FileInputComponent, + render: (args) => ({ + props: args, + template: ``, + }), +}; +export default meta; +type Story = StoryObj; + +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 }, +}; diff --git a/src/app/shared/ui/upload/single-upload/single-upload.component.ts b/src/app/shared/ui/upload/single-upload/single-upload.component.ts new file mode 100644 index 0000000..669cae2 --- /dev/null +++ b/src/app/shared/ui/upload/single-upload/single-upload.component.ts @@ -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: ` + + @if (progressPct() !== null) { + + } + `, +}) +export class SingleUploadComponent { + upload = input.required(); + + /** Narrow the status union once, in TS, so the template stays type-safe. */ + protected readonly progressPct = computed(() => { + const status = this.upload().status; + return status.type === 'uploading' ? status.progressPct : null; + }); + + remove = output(); + retry = output(); +} diff --git a/src/app/shared/ui/upload/single-upload/single-upload.stories.ts b/src/app/shared/ui/upload/single-upload/single-upload.stories.ts new file mode 100644 index 0000000..66c5833 --- /dev/null +++ b/src/app/shared/ui/upload/single-upload/single-upload.stories.ts @@ -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 = { + title: 'Molecules/SingleUpload', + component: SingleUploadComponent, + render: (args) => ({ + props: args, + template: ``, + }), +}; +export default meta; +type Story = StoryObj; + +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 } } }, +}; diff --git a/src/app/shared/ui/upload/upload-progress-bar/upload-progress-bar.component.ts b/src/app/shared/ui/upload/upload-progress-bar/upload-progress-bar.component.ts new file mode 100644 index 0000000..5b03c1d --- /dev/null +++ b/src/app/shared/ui/upload/upload-progress-bar/upload-progress-bar.component.ts @@ -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: ` + + {{ progressPct() }}% + `, +}) +export class UploadProgressBarComponent { + progressPct = input.required(); + + protected readonly progressLabel = $localize`:@@upload.progress.label:Uploadvoortgang`; +} diff --git a/src/app/shared/ui/upload/upload-progress-bar/upload-progress-bar.stories.ts b/src/app/shared/ui/upload/upload-progress-bar/upload-progress-bar.stories.ts new file mode 100644 index 0000000..5b83706 --- /dev/null +++ b/src/app/shared/ui/upload/upload-progress-bar/upload-progress-bar.stories.ts @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { UploadProgressBarComponent } from './upload-progress-bar.component'; + +const meta: Meta = { + title: 'Atoms/UploadProgressBar', + component: UploadProgressBarComponent, + render: (args) => ({ + props: args, + template: ``, + }), +}; +export default meta; +type Story = StoryObj; + +export const Halfway: Story = { args: { progressPct: 45 } }; +export const Almost: Story = { args: { progressPct: 92 } }; diff --git a/src/app/shared/ui/upload/upload-status-banner/upload-status-banner.component.ts b/src/app/shared/ui/upload/upload-status-banner/upload-status-banner.component.ts new file mode 100644 index 0000000..13fa187 --- /dev/null +++ b/src/app/shared/ui/upload/upload-status-banner/upload-status-banner.component.ts @@ -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: ` +
+ {{ message() }} +
+ `, +}) +export class UploadStatusBannerComponent { + message = input.required(); + type = input('info'); + + protected readonly alertType = computed(() => (this.type() === 'info' ? 'info' : this.type())); +} diff --git a/src/app/shared/ui/upload/upload-status-banner/upload-status-banner.stories.ts b/src/app/shared/ui/upload/upload-status-banner/upload-status-banner.stories.ts new file mode 100644 index 0000000..124f1c5 --- /dev/null +++ b/src/app/shared/ui/upload/upload-status-banner/upload-status-banner.stories.ts @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { UploadStatusBannerComponent } from './upload-status-banner.component'; + +const meta: Meta = { + title: 'Molecules/UploadStatusBanner', + component: UploadStatusBannerComponent, + render: (args) => ({ + props: args, + template: ``, + }), +}; +export default meta; +type Story = StoryObj; + +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.' }, +}; diff --git a/src/app/shared/ui/upload/upload-status-icon/upload-status-icon.component.ts b/src/app/shared/ui/upload/upload-status-icon/upload-status-icon.component.ts new file mode 100644 index 0000000..45a328e --- /dev/null +++ b/src/app/shared/ui/upload/upload-status-icon/upload-status-icon.component.ts @@ -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()) { + + {{ glyph()!.char }} + + } + `, +}) +export class UploadStatusIconComponent { + status = input.required(); + + protected readonly glyph = computed(() => { + 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; + } + }); +} diff --git a/src/app/shared/ui/upload/upload-status-icon/upload-status-icon.stories.ts b/src/app/shared/ui/upload/upload-status-icon/upload-status-icon.stories.ts new file mode 100644 index 0000000..67eb2a1 --- /dev/null +++ b/src/app/shared/ui/upload/upload-status-icon/upload-status-icon.stories.ts @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { UploadStatusIconComponent } from './upload-status-icon.component'; + +const meta: Meta = { + title: 'Atoms/UploadStatusIcon', + component: UploadStatusIconComponent, + render: (args) => ({ + props: args, + template: ``, + }), +}; +export default meta; +type Story = StoryObj; + +export const Complete: Story = { args: { status: 'complete' } }; +export const Failed: Story = { args: { status: 'failed' } }; +export const Uploading: Story = { args: { status: 'uploading' } };