Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/components/ui/FileUploader.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
.dropzone {
border: 2px dashed #ccc;
padding: 20px;
text-align: center;
cursor: pointer;
border-radius: 4px;
transition: border-color 0.2s ease, background-color 0.2s ease;
}

.dropzone:focus {
outline: 2px solid #007bff;
outline-offset: 2px;
}

.dropzoneDragging {
border-color: #007bff;
background-color: #f0f8ff;
}
112 changes: 112 additions & 0 deletions src/components/ui/FileUploader.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { vi } from 'vitest';
import FileUploader from './FileUploader';

describe('FileUploader', () => {
const mockOnFilesSelected = vi.fn();

beforeEach(() => {
mockOnFilesSelected.mockClear();
});

it('renders the dropzone with correct aria-label', () => {
render(<FileUploader accept=".pdf,.jpg" maxFiles={3} onFilesSelected={mockOnFilesSelected} />);
const dropzone = screen.getByRole('button', { name: /upload files/i });
expect(dropzone).toBeInTheDocument();
});

it('highlights on drag over', () => {
render(<FileUploader accept=".pdf,.jpg" maxFiles={3} onFilesSelected={mockOnFilesSelected} />);
const dropzone = screen.getByRole('button', { name: /upload files/i });
fireEvent.dragOver(dropzone);
expect(dropzone).toHaveClass('dropzoneDragging');

Check failure on line 22 in src/components/ui/FileUploader.test.tsx

View workflow job for this annotation

GitHub Actions / validate

src/components/ui/FileUploader.test.tsx > FileUploader > highlights on drag over

Error: expect(element).toHaveClass("dropzoneDragging") Expected the element to have class: dropzoneDragging Received: _dropzoneDragging_3fae4b ❯ src/components/ui/FileUploader.test.tsx:22:22 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { context: { assertionName: 'toHaveClass', meta: undefined } }
});

it('resets highlight on drag leave', () => {
render(<FileUploader accept=".pdf,.jpg" maxFiles={3} onFilesSelected={mockOnFilesSelected} />);
const dropzone = screen.getByRole('button', { name: /upload files/i });
fireEvent.dragOver(dropzone);
fireEvent.dragLeave(dropzone);
expect(dropzone).not.toHaveClass('dropzoneDragging');
});

it('resets highlight on drop', () => {
render(<FileUploader accept=".pdf,.jpg" maxFiles={3} onFilesSelected={mockOnFilesSelected} />);
const dropzone = screen.getByRole('button', { name: /upload files/i });
fireEvent.dragOver(dropzone);
const file = new File(['content'], 'test.pdf', { type: 'application/pdf' });
const dataTransfer = {
files: [file],
};
fireEvent.drop(dropzone, { dataTransfer });
expect(dropzone).not.toHaveClass('dropzoneDragging');
});

it('calls onFilesSelected with valid files on drop', () => {
render(<FileUploader accept=".pdf,.jpg" maxFiles={3} onFilesSelected={mockOnFilesSelected} />);
const dropzone = screen.getByRole('button', { name: /upload files/i });
const file = new File(['content'], 'test.pdf', { type: 'application/pdf' });
const dataTransfer = {
files: [file],
};
fireEvent.drop(dropzone, { dataTransfer });
expect(mockOnFilesSelected).toHaveBeenCalledWith([file]);
});

it('rejects invalid file type on drop', () => {
render(<FileUploader accept=".pdf,.jpg" maxFiles={3} onFilesSelected={mockOnFilesSelected} />);
const dropzone = screen.getByRole('button', { name: /upload files/i });
const file = new File(['content'], 'test.txt', { type: 'text/plain' });
const dataTransfer = {
files: [file],
};
fireEvent.drop(dropzone, { dataTransfer });
expect(mockOnFilesSelected).toHaveBeenCalledWith([], 'Invalid file type. Please upload files with accepted formats.');
});

it('rejects when exceeding maxFiles on drop', () => {
render(<FileUploader accept=".pdf,.jpg" maxFiles={1} onFilesSelected={mockOnFilesSelected} />);
const dropzone = screen.getByRole('button', { name: /upload files/i });
const file1 = new File(['content1'], 'test1.pdf', { type: 'application/pdf' });
const file2 = new File(['content2'], 'test2.pdf', { type: 'application/pdf' });
const dataTransfer = {
files: [file1, file2],
};
fireEvent.drop(dropzone, { dataTransfer });
expect(mockOnFilesSelected).toHaveBeenCalledWith([], 'Maximum 1 files allowed.');
});

it('triggers file input on Enter key', () => {
render(<FileUploader accept=".pdf,.jpg" maxFiles={3} onFilesSelected={mockOnFilesSelected} />);
const dropzone = screen.getByRole('button', { name: /upload files/i });
const input = screen.getByDisplayValue(''); // hidden input
const clickSpy = vi.spyOn(input, 'click');
fireEvent.keyDown(dropzone, { key: 'Enter' });
expect(clickSpy).toHaveBeenCalled();
});

it('triggers file input on Space key', () => {
render(<FileUploader accept=".pdf,.jpg" maxFiles={3} onFilesSelected={mockOnFilesSelected} />);
const dropzone = screen.getByRole('button', { name: /upload files/i });
const input = screen.getByDisplayValue(''); // hidden input
const clickSpy = vi.spyOn(input, 'click');
fireEvent.keyDown(dropzone, { key: ' ' });
expect(clickSpy).toHaveBeenCalled();
});

it('handles file input change with valid files', () => {
render(<FileUploader accept=".pdf,.jpg" maxFiles={3} onFilesSelected={mockOnFilesSelected} />);
const input = screen.getByDisplayValue(''); // hidden input
const file = new File(['content'], 'test.pdf', { type: 'application/pdf' });
fireEvent.change(input, { target: { files: [file] } });
expect(mockOnFilesSelected).toHaveBeenCalledWith([file]);
});

it('handles file input change with invalid files', () => {
render(<FileUploader accept=".pdf,.jpg" maxFiles={3} onFilesSelected={mockOnFilesSelected} />);
const input = screen.getByDisplayValue(''); // hidden input
const file = new File(['content'], 'test.txt', { type: 'text/plain' });
fireEvent.change(input, { target: { files: [file] } });
expect(mockOnFilesSelected).toHaveBeenCalledWith([], 'Invalid file type. Please upload files with accepted formats.');
});
});
99 changes: 99 additions & 0 deletions src/components/ui/FileUploader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import React, { useState, useRef } from 'react';
import styles from './FileUploader.module.css';

interface FileUploaderProps {
accept: string;
maxFiles: number;
onFilesSelected: (files: File[], error?: string) => void;
}

const FileUploader: React.FC<FileUploaderProps> = ({ accept, maxFiles, onFilesSelected }) => {
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);

const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(true);
};

const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
};

const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
validateAndProcess(files);
};

const handleClick = () => {
fileInputRef.current?.click();
};

const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
};

const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
validateAndProcess(files);
};

const validateAndProcess = (files: File[]) => {
const acceptedTypes = accept.split(',').map(type => type.trim().toLowerCase());
const invalidFiles = files.filter(file => {
const fileType = file.type.toLowerCase();
const fileName = file.name.toLowerCase();
return !acceptedTypes.some(type =>
fileType === type.replace('.', '') ||
fileName.endsWith(type) ||
(type.startsWith('.') && fileName.endsWith(type))
);
});

if (invalidFiles.length > 0) {
onFilesSelected([], 'Invalid file type. Please upload files with accepted formats.');
return;
}

if (files.length > maxFiles) {
onFilesSelected([], `Maximum ${maxFiles} files allowed.`);
return;
}

onFilesSelected(files);
};

return (
<div>
<div
className={isDragging ? styles.dropzoneDragging : styles.dropzone}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={handleClick}
onKeyDown={handleKeyDown}
tabIndex={0}
role="button"
aria-label="Upload files. Drag and drop or click to browse"
>
<p>Drag and drop files here or click to browse</p>
</div>
<input
ref={fileInputRef}
type="file"
accept={accept}
multiple={maxFiles > 1}
style={{ display: 'none' }}
onChange={handleInputChange}
/>
</div>
);
};

export default FileUploader;
Loading