feat(frontend): add UploadZone component for PLACEHOLDER document file upload

Presentational component with idle/uploading/error states, drag-and-drop,
client-side MIME type + 50 MB size validation, accessible touch targets (44px),
aria-live region, and indeterminate progress animation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-18 14:03:28 +02:00
committed by marcel
parent fa14a11244
commit f0bdcf334b
2 changed files with 185 additions and 0 deletions

View File

@@ -0,0 +1,100 @@
<script lang="ts">
const ALLOWED_TYPES = new Set(['application/pdf', 'image/jpeg', 'image/png', 'image/tiff']);
const MAX_SIZE_BYTES = 50 * 1024 * 1024;
let {
filename,
isUploading,
isDragging = $bindable(false),
error,
onFile,
onCancel
}: {
filename: string;
isUploading: boolean;
isDragging?: boolean;
error: string | null;
onFile?: (file: File) => void;
onCancel?: () => void;
} = $props();
let validationError = $state<string | null>(null);
const displayError = $derived(error ?? validationError);
function handleFile(file: File | undefined) {
if (!file) return;
validationError = null;
if (!ALLOWED_TYPES.has(file.type)) {
validationError = 'Dieser Dateityp wird nicht unterstützt (PDF, JPG, PNG, TIFF).';
return;
}
if (file.size > MAX_SIZE_BYTES) {
validationError = 'Die Datei ist zu groß (max. 50 MB).';
return;
}
onFile?.(file);
}
function handleChange(e: Event) {
const input = e.currentTarget as HTMLInputElement;
handleFile(input.files?.[0]);
}
function handleDrop(e: DragEvent) {
e.preventDefault();
isDragging = false;
handleFile(e.dataTransfer?.files[0]);
}
</script>
<div
class="flex flex-1 items-center justify-center bg-pdf-bg"
aria-live="polite"
aria-label={isUploading ? 'Datei wird hochgeladen' : 'Dateiupload-Bereich'}
>
{#if isUploading}
<div role="status" class="flex flex-col items-center gap-3 text-center">
<div class="h-0.5 w-48 overflow-hidden rounded-full bg-white/10">
<div class="h-full animate-[slide_1.4s_ease-in-out_infinite] bg-brand-mint/70"></div>
</div>
<p class="max-w-[200px] truncate text-xs font-medium text-brand-mint/70">{filename}</p>
<p class="text-xs text-white/40">Wird hochgeladen …</p>
<button
class="min-h-[44px] px-3 text-xs text-white/40 transition-colors hover:text-white/60"
onclick={onCancel}
>
Abbrechen
</button>
</div>
{:else}
<div
role="region"
aria-label="Datei ablegen"
class="flex flex-col items-center gap-3 rounded-sm border border-dashed p-8 text-center transition-colors
{isDragging ? 'border-brand-mint bg-brand-mint/5' : 'border-white/20'}"
ondragover={(e) => {
e.preventDefault();
isDragging = true;
}}
ondragleave={() => (isDragging = false)}
ondrop={handleDrop}
>
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 text-white/40">
</div>
<p class="max-w-[200px] truncate text-xs font-medium text-white/50">{filename}</p>
<p class="text-xs text-white/30">Noch keine Datei hochgeladen</p>
{#if displayError}
<p class="text-xs text-red-400">{displayError}</p>
{/if}
<label
class="flex min-h-[44px] cursor-pointer items-center rounded-sm bg-brand-navy px-4 py-1.5 text-xs font-bold tracking-widest text-white/90 uppercase"
aria-label="Datei auswählen"
>
Datei auswählen
<input type="file" class="sr-only" onchange={handleChange} />
</label>
<p class="text-xs text-white/20">oder Datei hier ablegen</p>
</div>
{/if}
</div>

View File

@@ -0,0 +1,85 @@
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import UploadZone from './UploadZone.svelte';
describe('UploadZone', () => {
describe('idle state', () => {
it('shows the filename in the upload zone', async () => {
render(UploadZone, {
props: { filename: 'brief_1920.pdf', isUploading: false, isDragging: false, error: null }
});
await expect.element(page.getByText('brief_1920.pdf')).toBeVisible();
});
it('shows "Datei auswählen" button', async () => {
render(UploadZone, {
props: { filename: 'scan.pdf', isUploading: false, isDragging: false, error: null }
});
await expect.element(page.getByText('Datei auswählen')).toBeVisible();
});
it('does not show the uploading animation', async () => {
render(UploadZone, {
props: { filename: 'scan.pdf', isUploading: false, isDragging: false, error: null }
});
expect(document.querySelector('[role="status"]')).toBeNull();
});
});
describe('uploading state', () => {
it('shows the uploading progress region', async () => {
render(UploadZone, {
props: { filename: 'scan.pdf', isUploading: true, isDragging: false, error: null }
});
await expect.element(page.getByRole('status')).toBeVisible();
});
it('shows Abbrechen button during upload', async () => {
render(UploadZone, {
props: { filename: 'scan.pdf', isUploading: true, isDragging: false, error: null }
});
await expect.element(page.getByText('Abbrechen')).toBeVisible();
});
it('calls onCancel when Abbrechen is clicked', async () => {
const onCancel = vi.fn();
render(UploadZone, {
props: { filename: 'scan.pdf', isUploading: true, isDragging: false, error: null, onCancel }
});
const btn = document.querySelector('button')!;
btn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(onCancel).toHaveBeenCalledOnce();
});
});
describe('error state', () => {
it('shows the error message', async () => {
render(UploadZone, {
props: {
filename: 'scan.pdf',
isUploading: false,
isDragging: false,
error: 'Dateityp nicht unterstützt'
}
});
await expect.element(page.getByText('Dateityp nicht unterstützt')).toBeVisible();
});
});
describe('file selection', () => {
it('does not call onFile for an unsupported MIME type', async () => {
const onFile = vi.fn();
render(UploadZone, {
props: { filename: 'scan.pdf', isUploading: false, isDragging: false, error: null, onFile }
});
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const docxFile = new File(['x'], 'test.docx', {
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
});
Object.defineProperty(input, 'files', { value: [docxFile], writable: false });
input.dispatchEvent(new Event('change', { bubbles: true }));
expect(onFile).not.toHaveBeenCalled();
});
});
});