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,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,
};
}