c7644ade69
@robonen/vue (toolkit): migrate to eslint flat config + composite tsconfig; fix composable + test type errors (writable computed returns, null guards, overload-compatible signatures, typed test helpers) — all type-level.
245 lines
6.2 KiB
TypeScript
245 lines
6.2 KiB
TypeScript
import { computed, shallowRef, toValue, watchEffect } from 'vue';
|
|
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
|
|
import { defaultDocument } from '@/types';
|
|
import type { ConfigurableDocument } from '@/types';
|
|
import { unrefElement } from '@/composables/component/unrefElement';
|
|
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
|
|
|
export interface UseFileDialogOptions extends ConfigurableDocument {
|
|
/**
|
|
* Allow selecting multiple files
|
|
*
|
|
* @default true
|
|
*/
|
|
multiple?: MaybeRefOrGetter<boolean>;
|
|
|
|
/**
|
|
* Comma-separated list of accepted file types (the input's `accept` attribute)
|
|
*
|
|
* @default '*'
|
|
*/
|
|
accept?: MaybeRefOrGetter<string>;
|
|
|
|
/**
|
|
* Hint for which camera/microphone to use on mobile capture (the input's `capture` attribute)
|
|
*/
|
|
capture?: MaybeRefOrGetter<string>;
|
|
|
|
/**
|
|
* Reset the selected files each time `open()` is called
|
|
*
|
|
* @default false
|
|
*/
|
|
reset?: MaybeRefOrGetter<boolean>;
|
|
|
|
/**
|
|
* Select directories instead of files (sets `webkitdirectory`)
|
|
*
|
|
* @default false
|
|
*/
|
|
directory?: MaybeRefOrGetter<boolean>;
|
|
|
|
/**
|
|
* Initial files to seed `files` with before any dialog is opened
|
|
*/
|
|
initialFiles?: File[] | FileList;
|
|
|
|
/**
|
|
* Use a custom `<input type="file">` element instead of an internally created one
|
|
*/
|
|
input?: MaybeComputedElementRef<HTMLInputElement>;
|
|
}
|
|
|
|
/**
|
|
* Subscribe to an event; returns an unsubscribe function.
|
|
*/
|
|
export type FileDialogEventHookOn<T = void> = (callback: (param: T) => void) => { off: () => void };
|
|
|
|
export interface UseFileDialogReturn {
|
|
/**
|
|
* The currently selected files, or `null` when none are selected
|
|
*/
|
|
files: ComputedRef<FileList | null>;
|
|
|
|
/**
|
|
* Open the file dialog, optionally overriding options for this call only
|
|
*/
|
|
open: (localOptions?: Partial<UseFileDialogOptions>) => void;
|
|
|
|
/**
|
|
* Clear the current selection
|
|
*/
|
|
reset: () => void;
|
|
|
|
/**
|
|
* Register a callback fired when the selection changes
|
|
*/
|
|
onChange: FileDialogEventHookOn<FileList | null>;
|
|
|
|
/**
|
|
* Register a callback fired when the dialog is dismissed without a selection
|
|
*/
|
|
onCancel: FileDialogEventHookOn;
|
|
}
|
|
|
|
const DEFAULT_OPTIONS: UseFileDialogOptions = {
|
|
multiple: true,
|
|
accept: '*',
|
|
reset: false,
|
|
directory: false,
|
|
};
|
|
|
|
interface EventHook<T> {
|
|
on: FileDialogEventHookOn<T>;
|
|
trigger: (param: T) => void;
|
|
}
|
|
|
|
function createEventHook<T = void>(): EventHook<T> {
|
|
const callbacks = new Set<(param: T) => void>();
|
|
|
|
const on: FileDialogEventHookOn<T> = (callback) => {
|
|
callbacks.add(callback);
|
|
return {
|
|
off: () => {
|
|
callbacks.delete(callback);
|
|
},
|
|
};
|
|
};
|
|
|
|
const trigger = (param: T): void => {
|
|
callbacks.forEach(cb => cb(param));
|
|
};
|
|
|
|
return { on, trigger };
|
|
}
|
|
|
|
function toFileList(files: File[] | FileList | undefined): FileList | null {
|
|
if (!files)
|
|
return null;
|
|
|
|
if (typeof FileList !== 'undefined' && files instanceof FileList)
|
|
return files;
|
|
|
|
// Materialize a plain array into a FileList via DataTransfer when available.
|
|
if (typeof DataTransfer !== 'undefined') {
|
|
const dt = new DataTransfer();
|
|
for (const file of files)
|
|
dt.items.add(file);
|
|
return dt.files;
|
|
}
|
|
|
|
// Fallback: build a FileList-like object (environments without DataTransfer, e.g. jsdom).
|
|
const array = Array.from(files);
|
|
const list = {
|
|
length: array.length,
|
|
item: (index: number) => array[index] ?? null,
|
|
[Symbol.iterator]: () => array[Symbol.iterator](),
|
|
} as unknown as FileList;
|
|
array.forEach((file, index) => {
|
|
(list as unknown as Record<number, File>)[index] = file;
|
|
});
|
|
return list;
|
|
}
|
|
|
|
/**
|
|
* @name useFileDialog
|
|
* @category Browser
|
|
* @description Open a native file dialog programmatically and reactively track the selected files.
|
|
*
|
|
* @param {UseFileDialogOptions} [options={}] Options
|
|
* @returns {UseFileDialogReturn} `files`, `open`, `reset`, `onChange`, and `onCancel`
|
|
*
|
|
* @example
|
|
* const { files, open, onChange } = useFileDialog({ accept: 'image/*' });
|
|
* onChange((selected) => console.log(selected));
|
|
* open();
|
|
*
|
|
* @example
|
|
* // Override options for a single call
|
|
* const { open } = useFileDialog();
|
|
* open({ multiple: false, accept: '.pdf' });
|
|
*
|
|
* @since 0.0.15
|
|
*/
|
|
export function useFileDialog(options: UseFileDialogOptions = {}): UseFileDialogReturn {
|
|
const {
|
|
document = defaultDocument,
|
|
} = options;
|
|
|
|
const files = shallowRef<FileList | null>(toFileList(options.initialFiles));
|
|
|
|
const { on: onChange, trigger: changeTrigger } = createEventHook<FileList | null>();
|
|
const { on: onCancel, trigger: cancelTrigger } = createEventHook();
|
|
|
|
const inputRef = shallowRef<HTMLInputElement | undefined>();
|
|
|
|
// Eagerly resolve the input element (custom or internally created) and wire its
|
|
// handlers, re-running if a reactive `options.input` target changes.
|
|
watchEffect(() => {
|
|
const input = unrefElement(options.input)
|
|
?? (document ? document.createElement('input') : undefined);
|
|
|
|
if (input) {
|
|
input.type = 'file';
|
|
input.onchange = (event: Event) => {
|
|
const result = event.target as HTMLInputElement;
|
|
files.value = result.files;
|
|
changeTrigger(files.value);
|
|
};
|
|
input.oncancel = () => {
|
|
cancelTrigger();
|
|
};
|
|
}
|
|
|
|
inputRef.value = input;
|
|
});
|
|
|
|
const reset = (): void => {
|
|
files.value = null;
|
|
const el = inputRef.value;
|
|
if (el && el.value) {
|
|
el.value = '';
|
|
changeTrigger(null);
|
|
}
|
|
};
|
|
|
|
const applyOptions = (opts: UseFileDialogOptions): void => {
|
|
const el = inputRef.value;
|
|
if (!el)
|
|
return;
|
|
|
|
el.multiple = toValue(opts.multiple)!;
|
|
el.accept = toValue(opts.accept)!;
|
|
el.webkitdirectory = toValue(opts.directory)!;
|
|
if ('capture' in opts)
|
|
el.capture = toValue(opts.capture)!;
|
|
};
|
|
|
|
const open = (localOptions?: Partial<UseFileDialogOptions>): void => {
|
|
const el = inputRef.value;
|
|
if (!el)
|
|
return;
|
|
|
|
const mergedOptions: UseFileDialogOptions = {
|
|
...DEFAULT_OPTIONS,
|
|
...options,
|
|
...localOptions,
|
|
};
|
|
|
|
applyOptions(mergedOptions);
|
|
|
|
if (toValue(mergedOptions.reset))
|
|
reset();
|
|
|
|
el.click();
|
|
};
|
|
|
|
return {
|
|
files: computed(() => files.value),
|
|
open,
|
|
reset,
|
|
onChange,
|
|
onCancel,
|
|
};
|
|
}
|