feat(vue): expand @robonen/vue composable collection

Composables, tests, category barrels, and README for @robonen/vue.
This commit is contained in:
2026-06-08 15:51:16 +07:00
parent 9a912f7a77
commit 59e995d0b5
369 changed files with 36554 additions and 188 deletions
@@ -0,0 +1,288 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, ref } from 'vue';
import { useDropZone } from '.';
interface FakeDataTransfer {
files: File[];
items: Array<{ type: string }>;
dropEffect: string;
}
function makeFile(name = 'a.png', type = 'image/png'): File {
return new File(['x'], name, { type });
}
// jsdom lacks DragEvent / DataTransfer, so we synthesize an Event with a dataTransfer payload.
function dispatchDrag(
el: EventTarget,
type: 'dragenter' | 'dragover' | 'dragleave' | 'drop',
files: File[] = [],
): { event: Event; dataTransfer: FakeDataTransfer } {
const dataTransfer: FakeDataTransfer = {
files,
items: files.map(f => ({ type: f.type })),
dropEffect: 'none',
};
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperty(event, 'dataTransfer', { value: dataTransfer, configurable: true });
el.dispatchEvent(event);
return { event, dataTransfer };
}
describe(useDropZone, () => {
let el: HTMLElement;
beforeEach(() => {
el = document.createElement('div');
document.body.appendChild(el);
});
afterEach(() => {
el.remove();
vi.unstubAllGlobals();
});
it('exposes reactive state', () => {
const scope = effectScope();
scope.run(() => {
const { isOverDropZone, files, isSupported } = useDropZone(el);
expect(isOverDropZone.value).toBeFalsy();
expect(files.value).toBeNull();
expect(isSupported).toBeDefined();
});
scope.stop();
});
it('sets isOverDropZone on dragenter and clears on matching dragleave', () => {
const scope = effectScope();
scope.run(() => {
const { isOverDropZone } = useDropZone(el);
dispatchDrag(el, 'dragenter', [makeFile()]);
expect(isOverDropZone.value).toBeTruthy();
dispatchDrag(el, 'dragleave', [makeFile()]);
expect(isOverDropZone.value).toBeFalsy();
});
scope.stop();
});
it('uses a counter so nested enter/leave keeps isOverDropZone true', () => {
const scope = effectScope();
scope.run(() => {
const { isOverDropZone } = useDropZone(el);
dispatchDrag(el, 'dragenter', [makeFile()]);
dispatchDrag(el, 'dragenter', [makeFile()]);
expect(isOverDropZone.value).toBeTruthy();
dispatchDrag(el, 'dragleave', [makeFile()]);
expect(isOverDropZone.value).toBeTruthy();
dispatchDrag(el, 'dragleave', [makeFile()]);
expect(isOverDropZone.value).toBeFalsy();
});
scope.stop();
});
it('collects dropped files and resets isOverDropZone', () => {
const scope = effectScope();
scope.run(() => {
const { files, isOverDropZone } = useDropZone(el);
dispatchDrag(el, 'dragenter', [makeFile()]);
const dropped = [makeFile('one.png'), makeFile('two.png')];
dispatchDrag(el, 'drop', dropped);
expect(files.value).toHaveLength(2);
expect(files.value?.[0]!.name).toBe('one.png');
expect(isOverDropZone.value).toBeFalsy();
});
scope.stop();
});
it('invokes lifecycle callbacks', () => {
const scope = effectScope();
scope.run(() => {
const onEnter = vi.fn();
const onOver = vi.fn();
const onLeave = vi.fn();
const onDrop = vi.fn();
useDropZone(el, { onEnter, onOver, onLeave, onDrop });
const f = [makeFile()];
dispatchDrag(el, 'dragenter', f);
dispatchDrag(el, 'dragover', f);
dispatchDrag(el, 'dragleave', f);
dispatchDrag(el, 'drop', f);
expect(onEnter).toHaveBeenCalledTimes(1);
expect(onOver).toHaveBeenCalledTimes(1);
expect(onLeave).toHaveBeenCalledTimes(1);
expect(onDrop).toHaveBeenCalledTimes(1);
expect(onEnter).toHaveBeenCalledWith(null, expect.any(Event));
expect(onDrop.mock.calls[0]![0]).toHaveLength(1);
});
scope.stop();
});
it('accepts a shorthand onDrop function as options', () => {
const scope = effectScope();
scope.run(() => {
const onDrop = vi.fn();
useDropZone(el, onDrop);
dispatchDrag(el, 'drop', [makeFile()]);
expect(onDrop).toHaveBeenCalledTimes(1);
});
scope.stop();
});
it('respects multiple: false by keeping only the first file', () => {
const scope = effectScope();
scope.run(() => {
const { files } = useDropZone(el, { multiple: false });
// Two files dragged: validation should reject, so drop is ignored
dispatchDrag(el, 'drop', [makeFile('a.png'), makeFile('b.png')]);
expect(files.value).toBeNull();
// Single file passes and only the first is kept
dispatchDrag(el, 'drop', [makeFile('solo.png')]);
expect(files.value).toHaveLength(1);
expect(files.value?.[0]!.name).toBe('solo.png');
});
scope.stop();
});
it('filters by dataTypes array', () => {
const scope = effectScope();
scope.run(() => {
const onDrop = vi.fn();
const { files } = useDropZone(el, { dataTypes: ['image/png'], onDrop });
// wrong type rejected
dispatchDrag(el, 'drop', [makeFile('doc.pdf', 'application/pdf')]);
expect(files.value).toBeNull();
expect(onDrop).not.toHaveBeenCalled();
// correct type accepted
dispatchDrag(el, 'drop', [makeFile('img.png', 'image/png')]);
expect(files.value).toHaveLength(1);
expect(onDrop).toHaveBeenCalledTimes(1);
});
scope.stop();
});
it('supports dataTypes as a predicate function', () => {
const scope = effectScope();
scope.run(() => {
const predicate = vi.fn((types: readonly string[]) => types.includes('image/png'));
const { files } = useDropZone(el, { dataTypes: predicate });
dispatchDrag(el, 'drop', [makeFile('img.png', 'image/png')]);
expect(predicate).toHaveBeenCalled();
expect(files.value).toHaveLength(1);
});
scope.stop();
});
it('reacts to a reactive dataTypes ref', () => {
const scope = effectScope();
scope.run(() => {
const allowed = ref<string[]>(['image/png']);
const { files } = useDropZone(el, { dataTypes: allowed });
dispatchDrag(el, 'drop', [makeFile('doc.pdf', 'application/pdf')]);
expect(files.value).toBeNull();
allowed.value = ['application/pdf'];
dispatchDrag(el, 'drop', [makeFile('doc.pdf', 'application/pdf')]);
expect(files.value).toHaveLength(1);
});
scope.stop();
});
it('sets dropEffect to none for invalid drags', () => {
const scope = effectScope();
scope.run(() => {
useDropZone(el, { dataTypes: ['image/png'] });
const { dataTransfer } = dispatchDrag(el, 'dragenter', [makeFile('doc.pdf', 'application/pdf')]);
expect(dataTransfer.dropEffect).toBe('none');
});
scope.stop();
});
it('sets dropEffect to copy for valid drags', () => {
const scope = effectScope();
scope.run(() => {
useDropZone(el, { dataTypes: ['image/png'] });
const { dataTransfer } = dispatchDrag(el, 'dragenter', [makeFile('img.png', 'image/png')]);
expect(dataTransfer.dropEffect).toBe('copy');
});
scope.stop();
});
it('preventDefaultForUnhandled calls preventDefault on invalid drags', () => {
const scope = effectScope();
scope.run(() => {
useDropZone(el, { dataTypes: ['image/png'], preventDefaultForUnhandled: true });
const { event } = dispatchDrag(el, 'dragenter', [makeFile('doc.pdf', 'application/pdf')]);
expect(event.defaultPrevented).toBeTruthy();
});
scope.stop();
});
it('works with a reactive element ref target', async () => {
const scope = effectScope();
await scope.run(async () => {
const target = ref<HTMLElement | null>(null);
const { isOverDropZone } = useDropZone(target);
target.value = el;
await nextTick();
dispatchDrag(el, 'dragenter', [makeFile()]);
expect(isOverDropZone.value).toBeTruthy();
});
scope.stop();
});
it('works with document as the target', () => {
const scope = effectScope();
scope.run(() => {
const { isOverDropZone } = useDropZone(document);
dispatchDrag(document, 'dragenter', [makeFile()]);
expect(isOverDropZone.value).toBeTruthy();
});
scope.stop();
});
it('stops listening after the scope is disposed', () => {
const onDrop = vi.fn();
const scope = effectScope();
scope.run(() => {
useDropZone(el, { onDrop });
});
scope.stop();
dispatchDrag(el, 'drop', [makeFile()]);
expect(onDrop).not.toHaveBeenCalled();
});
it('reports isSupported via the configurable window option', () => {
const scope = effectScope();
scope.run(() => {
const { isSupported } = useDropZone(el, { window: undefined });
expect(isSupported.value).toBeFalsy();
});
scope.stop();
});
});
@@ -0,0 +1,205 @@
import type { ComputedRef, MaybeRef, MaybeRefOrGetter, ShallowRef } from 'vue';
import { shallowRef, toValue, unref } from 'vue';
import { isFunction } from '@robonen/stdlib';
import { useEventListener } from '@/composables/browser/useEventListener';
import { useSupported } from '@/composables/utilities/useSupported';
import { unrefElement } from '@/composables/component/unrefElement';
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
import { defaultNavigator, defaultWindow } from '@/types';
import type { ConfigurableNavigator, ConfigurableWindow } from '@/types';
export type UseDropZoneDataTypes = MaybeRef<readonly string[]> | ((types: readonly string[]) => boolean);
export interface UseDropZoneOptions extends ConfigurableWindow, ConfigurableNavigator {
/**
* Allowed data types. If not set, all data types are allowed.
* Can also be a predicate that receives the dragged item types and returns whether they are valid.
*/
dataTypes?: UseDropZoneDataTypes;
/**
* Allow multiple files to be dropped.
*
* @default true
*/
multiple?: boolean;
/**
* Call `preventDefault` even for drags that fail validation, suppressing the browser's default handling.
*
* @default false
*/
preventDefaultForUnhandled?: boolean;
/**
* Fired when valid files are dropped on the target.
*/
onDrop?: (files: File[] | null, event: DragEvent) => void;
/**
* Fired when a drag enters the target.
*/
onEnter?: (files: File[] | null, event: DragEvent) => void;
/**
* Fired when a drag leaves the target.
*/
onLeave?: (files: File[] | null, event: DragEvent) => void;
/**
* Fired repeatedly while a drag hovers over the target.
*/
onOver?: (files: File[] | null, event: DragEvent) => void;
}
export interface UseDropZoneReturn {
/**
* Whether a valid drag is currently hovering over the target.
*/
isOverDropZone: ShallowRef<boolean>;
/**
* The dropped files, or `null` when nothing has been dropped yet.
*/
files: ShallowRef<File[] | null>;
/**
* Whether the Drag and Drop API is available in the current environment.
*/
isSupported: ComputedRef<boolean>;
}
type DropZoneEventType = 'enter' | 'over' | 'leave' | 'drop';
/**
* @name useDropZone
* @category Elements
* @description Create a drag-and-drop file drop zone on a target element or document.
*
* @param {MaybeComputedElementRef | MaybeRefOrGetter<Document | null | undefined>} target - The element (or document) acting as the drop zone.
* @param {UseDropZoneOptions | UseDropZoneOptions['onDrop']} [options] - Drop zone options, or a shorthand `onDrop` callback.
* @returns {UseDropZoneReturn} The reactive drop zone state.
*
* @example
* const dropZone = useTemplateRef<HTMLElement>('dropZone');
* const { isOverDropZone, files } = useDropZone(dropZone, {
* dataTypes: ['image/png'],
* onDrop: (files) => console.log(files),
* });
*
* @since 0.0.15
*/
export function useDropZone(
target: MaybeComputedElementRef | MaybeRefOrGetter<Document | null | undefined>,
options: UseDropZoneOptions | UseDropZoneOptions['onDrop'] = {},
): UseDropZoneReturn {
const _options: UseDropZoneOptions = isFunction(options) ? { onDrop: options } : options;
const {
window = defaultWindow,
navigator = defaultNavigator,
multiple = true,
preventDefaultForUnhandled = false,
} = _options;
const isOverDropZone = shallowRef(false);
const files = shallowRef<File[] | null>(null);
const isSupported = useSupported(() => window && 'DataTransfer' in window);
let counter = 0;
let isValid = true;
const getFiles = (event: DragEvent): File[] | null => {
const list = Array.from(event.dataTransfer?.files ?? []);
if (list.length === 0)
return null;
return multiple ? list : [list[0]!];
};
const checkDataTypes = (types: readonly string[]): boolean => {
// `dataTypes` may be a predicate function, so unwrap with `unref` (not `toValue`,
// which would call a function as a getter).
const dataTypes = unref(_options.dataTypes);
if (isFunction(dataTypes))
return dataTypes(types);
if (!dataTypes?.length)
return true;
if (types.length === 0)
return false;
return types.every(type => dataTypes.some(allowed => type.includes(allowed)));
};
const checkValidity = (items: DataTransferItemList): boolean => {
const types = Array.from(items ?? []).map(item => item.type);
const dataTypesValid = checkDataTypes(types);
const multipleFilesValid = multiple || items.length <= 1;
return dataTypesValid && multipleFilesValid;
};
// Safari fires drag events without populating `dataTransfer.items`, so validation
// cannot be trusted there — always accept the drag and let `drop` resolve files.
const isSafari = (): boolean => {
if (!navigator || !window)
return false;
return /^(?:(?!chrome|android).)*safari/i.test(navigator.userAgent) && !('chrome' in window);
};
const handleDragEvent = (event: DragEvent, type: DropZoneEventType): void => {
const items = event.dataTransfer?.items;
isValid = (items && checkValidity(items)) ?? false;
if (preventDefaultForUnhandled)
event.preventDefault();
if (!isSafari() && !isValid) {
if (event.dataTransfer)
event.dataTransfer.dropEffect = 'none';
return;
}
event.preventDefault();
if (event.dataTransfer)
event.dataTransfer.dropEffect = 'copy';
const currentFiles = getFiles(event);
switch (type) {
case 'enter':
counter += 1;
isOverDropZone.value = true;
_options.onEnter?.(null, event);
break;
case 'over':
_options.onOver?.(null, event);
break;
case 'leave':
counter -= 1;
if (counter === 0)
isOverDropZone.value = false;
_options.onLeave?.(null, event);
break;
case 'drop':
counter = 0;
isOverDropZone.value = false;
if (isValid) {
files.value = currentFiles;
_options.onDrop?.(currentFiles, event);
}
break;
}
};
const resolveTarget = (): EventTarget | null | undefined => {
const value = toValue(target as MaybeRefOrGetter<unknown>);
if (value instanceof Document)
return value;
return unrefElement(target as MaybeComputedElementRef);
};
useEventListener<DragEvent>(resolveTarget, 'dragenter', event => handleDragEvent(event, 'enter'));
useEventListener<DragEvent>(resolveTarget, 'dragover', event => handleDragEvent(event, 'over'));
useEventListener<DragEvent>(resolveTarget, 'dragleave', event => handleDragEvent(event, 'leave'));
useEventListener<DragEvent>(resolveTarget, 'drop', event => handleDragEvent(event, 'drop'));
return {
isOverDropZone,
files,
isSupported,
};
}