feat(bulk-upload): add BulkDropZone component

Full-panel drop target that supports multi-file selection via drag-and-drop
or file picker. Fires onFilesAdded callback with the full File array.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-24 16:40:04 +02:00
parent 4248d8af72
commit 22bba5cfcd
2 changed files with 103 additions and 0 deletions

View File

@@ -0,0 +1,64 @@
<script lang="ts">
let {
onFilesAdded
}: {
onFilesAdded: (files: File[]) => void;
} = $props();
let isDragging = $state(false);
</script>
<div class="flex min-h-[300px] flex-1 flex-col items-center justify-center bg-pdf-bg">
<div
role="region"
aria-label="Dateien ablegen"
data-testid="bulk-drop-zone"
class="flex flex-col items-center gap-4 rounded-sm border border-dashed p-12 text-center transition-colors
{isDragging ? 'border-brand-mint ring-2 ring-brand-mint' : 'border-white/20'}"
ondragover={(e) => {
e.preventDefault();
isDragging = true;
}}
ondragleave={() => (isDragging = false)}
ondrop={(e) => {
e.preventDefault();
isDragging = false;
if (e.dataTransfer && e.dataTransfer.files.length > 0) {
onFilesAdded(Array.from(e.dataTransfer.files));
}
}}
>
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 text-white/40">
<svg
width="16"
height="16"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<polygon
fill="currentColor"
points="6 12.5 16 2 26 12.5 24.5714286 14 16.999 6.049 17 30 15 30 14.999 6.051 7.42857143 14"
/>
</svg>
</div>
<p class="text-sm font-medium text-white/60">PDF-Dateien hier ablegen</p>
<p class="text-xs text-white/30">oder</p>
<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="Dateien auswählen"
>
Dateien auswählen
<input
type="file"
multiple
accept="application/pdf,image/jpeg,image/png,image/tiff"
class="sr-only"
onchange={(e) => {
const files = Array.from(e.currentTarget.files ?? []);
if (files.length > 0) onFilesAdded(files);
}}
/>
</label>
</div>
</div>

View File

@@ -0,0 +1,39 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import BulkDropZone from './BulkDropZone.svelte';
afterEach(cleanup);
describe('BulkDropZone', () => {
it('file input has multiple attribute', async () => {
const { container } = render(BulkDropZone, { onFilesAdded: vi.fn() });
const input = container.querySelector('input[type="file"]');
expect(input).not.toBeNull();
expect(input?.hasAttribute('multiple')).toBe(true);
});
it('fires onFilesAdded with selected files when 3 files are picked via input', async () => {
const onFilesAdded = vi.fn();
render(BulkDropZone, { onFilesAdded });
const files = [
new File(['a'], 'a.pdf', { type: 'application/pdf' }),
new File(['b'], 'b.pdf', { type: 'application/pdf' }),
new File(['c'], 'c.pdf', { type: 'application/pdf' })
];
const input = page.getByRole('button', { name: /Dateien auswählen/i });
await userEvent.upload(input, files);
expect(onFilesAdded).toHaveBeenCalledOnce();
const received: File[] = onFilesAdded.mock.calls[0][0];
expect(received).toHaveLength(3);
expect(received.map((f) => f.name)).toEqual(['a.pdf', 'b.pdf', 'c.pdf']);
});
it('shows drop hint text', async () => {
render(BulkDropZone, { onFilesAdded: vi.fn() });
await expect.element(page.getByText(/hier ablegen/i)).toBeInTheDocument();
});
});