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:
100
frontend/src/lib/components/document/UploadZone.svelte
Normal file
100
frontend/src/lib/components/document/UploadZone.svelte
Normal 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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user