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; /** * Comma-separated list of accepted file types (the input's `accept` attribute) * * @default '*' */ accept?: MaybeRefOrGetter; /** * Hint for which camera/microphone to use on mobile capture (the input's `capture` attribute) */ capture?: MaybeRefOrGetter; /** * Reset the selected files each time `open()` is called * * @default false */ reset?: MaybeRefOrGetter; /** * Select directories instead of files (sets `webkitdirectory`) * * @default false */ directory?: MaybeRefOrGetter; /** * Initial files to seed `files` with before any dialog is opened */ initialFiles?: File[] | FileList; /** * Use a custom `` element instead of an internally created one */ input?: MaybeComputedElementRef; } /** * Subscribe to an event; returns an unsubscribe function. */ export type FileDialogEventHookOn = (callback: (param: T) => void) => { off: () => void }; export interface UseFileDialogReturn { /** * The currently selected files, or `null` when none are selected */ files: ComputedRef; /** * Open the file dialog, optionally overriding options for this call only */ open: (localOptions?: Partial) => void; /** * Clear the current selection */ reset: () => void; /** * Register a callback fired when the selection changes */ onChange: FileDialogEventHookOn; /** * 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 { on: FileDialogEventHookOn; trigger: (param: T) => void; } function createEventHook(): EventHook { const callbacks = new Set<(param: T) => void>(); const on: FileDialogEventHookOn = (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)[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(toFileList(options.initialFiles)); const { on: onChange, trigger: changeTrigger } = createEventHook(); const { on: onCancel, trigger: cancelTrigger } = createEventHook(); const inputRef = shallowRef(); // 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): 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, }; }