import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FileDropzone } from './file-dropzone';
import { DROPZONE_CHANGE_EVENT, DROPZONE_REJECT_EVENT } from './file-dropzone.types';
const MARKUP = `
`;
function setup(): HTMLElement {
document.body.innerHTML = MARKUP;
return document.body.firstElementChild as HTMLElement;
}
function file(name: string, type = 'text/plain', size = 4): File {
return new File(['x'.repeat(size)], name, { type });
}
function items(root: HTMLElement): HTMLElement[] {
return Array.from(root.querySelectorAll('[data-c42-dropzone-item]'));
}
function dropEvent(files: File[]): Event {
const event = new Event('drop', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'dataTransfer', { value: { files } });
return event;
}
describe('FileDropzone', () => {
beforeEach(() => {
document.body.innerHTML = '';
});
it('throws when the input is missing', () => {
document.body.innerHTML = '';
expect(() => new FileDropzone(document.body.firstElementChild as HTMLElement)).toThrow(
/\[42\/file-dropzone\]/,
);
});
it('accepts a valid file and emits change', () => {
const root = setup();
const dz = new FileDropzone(root);
const spy = vi.fn();
dz.on(DROPZONE_CHANGE_EVENT, spy);
dz.addFiles([file('a.txt')]);
expect(dz.files).toHaveLength(1);
expect((spy.mock.calls[0][0] as CustomEvent).detail.files[0].name).toBe('a.txt');
});
it('rejects files over maxSize', () => {
const root = setup();
const dz = new FileDropzone(root, { maxSize: 5 });
const spy = vi.fn();
dz.on(DROPZONE_REJECT_EVENT, spy);
dz.addFiles([file('big.txt', 'text/plain', 10)]);
expect(dz.files).toHaveLength(0);
expect((spy.mock.calls[0][0] as CustomEvent).detail.reason).toBe('too-large');
});
it('rejects unaccepted types by extension and mime', () => {
const root = setup();
const dz = new FileDropzone(root, { accept: '.png,image/jpeg', multiple: true });
const spy = vi.fn();
dz.on(DROPZONE_REJECT_EVENT, spy);
dz.addFiles([file('doc.txt', 'text/plain')]);
expect(dz.files).toHaveLength(0);
expect((spy.mock.calls[0][0] as CustomEvent).detail.reason).toBe('unaccepted-type');
dz.addFiles([file('pic.png', 'image/png')]);
dz.addFiles([file('photo.jpg', 'image/jpeg')]);
expect(dz.files).toHaveLength(2);
});
it('honors image/* wildcard', () => {
const root = setup();
const dz = new FileDropzone(root, { accept: 'image/*', multiple: true });
dz.addFiles([file('a.png', 'image/png'), file('b.gif', 'image/gif'), file('c.txt', 'text/plain')]);
expect(dz.files).toHaveLength(2);
});
it('replaces the file in single mode', () => {
const root = setup();
const dz = new FileDropzone(root);
dz.addFiles([file('a.txt')]);
dz.addFiles([file('b.txt')]);
expect(dz.files).toHaveLength(1);
expect(dz.files[0].name).toBe('b.txt');
});
it('accumulates and enforces maxFiles in multiple mode', () => {
const root = setup();
const dz = new FileDropzone(root, { multiple: true, maxFiles: 2 });
const spy = vi.fn();
dz.on(DROPZONE_REJECT_EVENT, spy);
dz.addFiles([file('a.txt'), file('b.txt'), file('c.txt')]);
expect(dz.files).toHaveLength(2);
expect((spy.mock.calls[0][0] as CustomEvent).detail.reason).toBe('max-files');
});
it('runs a custom validate hook', () => {
const root = setup();
const dz = new FileDropzone(root, {
validate: (f) => (f.name.startsWith('ok') ? true : 'bad name'),
});
const spy = vi.fn();
dz.on(DROPZONE_REJECT_EVENT, spy);
dz.addFiles([file('nope.txt')]);
expect(dz.files).toHaveLength(0);
expect((spy.mock.calls[0][0] as CustomEvent).detail.message).toBe('bad name');
dz.addFiles([file('ok.txt')]);
expect(dz.files).toHaveLength(1);
});
it('renders a list item with a remove button per file', () => {
const root = setup();
const dz = new FileDropzone(root, { multiple: true });
dz.addFiles([file('a.txt'), file('b.txt')]);
expect(items(root)).toHaveLength(2);
expect(root.querySelector('[data-c42-dropzone-name]')!.textContent).toBe('a.txt');
});
it('removes a file via the rendered remove button', () => {
const root = setup();
const dz = new FileDropzone(root, { multiple: true });
dz.addFiles([file('a.txt'), file('b.txt')]);
root.querySelector('[data-c42-dropzone-remove]')!.click();
expect(dz.files).toHaveLength(1);
expect(dz.files[0].name).toBe('b.txt');
});
it('accepts dropped files and toggles drag state', () => {
const root = setup();
const dz = new FileDropzone(root, { multiple: true });
const zone = root.querySelector('[data-c42-dropzone-zone]')!;
const over = new Event('dragover', { bubbles: true, cancelable: true });
zone.dispatchEvent(over);
expect(zone.dataset.state).toBe('dragging');
zone.dispatchEvent(dropEvent([file('a.txt')]));
expect(zone.dataset.state).toBe('idle');
expect(dz.files).toHaveLength(1);
});
it('ignores interaction when disabled', () => {
const root = setup();
const dz = new FileDropzone(root, { disabled: true });
dz.addFiles([file('a.txt')]);
expect(dz.files).toHaveLength(0);
expect(root.hasAttribute('data-disabled')).toBe(true);
});
it('clears all files', () => {
const root = setup();
const dz = new FileDropzone(root, { multiple: true });
dz.addFiles([file('a.txt'), file('b.txt')]);
dz.clear();
expect(dz.files).toHaveLength(0);
expect(dz.getState().count).toBe(0);
});
it('stops reacting after destroy', () => {
const root = setup();
const dz = new FileDropzone(root, { multiple: true });
dz.destroy();
const zone = root.querySelector('[data-c42-dropzone-zone]')!;
zone.dispatchEvent(dropEvent([file('a.txt')]));
expect(dz.files).toHaveLength(0);
});
});