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