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,314 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, ref } from 'vue';
import type { FileSystemFileHandle, UseFileSystemAccessReturn } from '.';
import { useFileSystemAccess } from '.';
interface WritableSpy {
write: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
}
function makeFile(contents = 'hello world', name = 'demo.txt', type = 'text/plain'): File {
const file = {
name,
type,
size: contents.length,
lastModified: 1234,
text: vi.fn(async () => contents),
arrayBuffer: vi.fn(async () => new ArrayBuffer(contents.length)),
};
return file as unknown as File;
}
function makeHandle(file: File): { handle: FileSystemFileHandle; writable: WritableSpy } {
const writable: WritableSpy = {
write: vi.fn(async () => {}),
close: vi.fn(async () => {}),
};
const handle = {
getFile: vi.fn(async () => file),
createWritable: vi.fn(async () => writable),
} as unknown as FileSystemFileHandle;
return { handle, writable };
}
function stubWindow(handle?: FileSystemFileHandle) {
const showOpenFilePicker = vi.fn(async () => (handle ? [handle] : []));
const showSaveFilePicker = vi.fn(async () => handle);
const window = {
showOpenFilePicker,
showSaveFilePicker,
} as unknown as Window;
return { window, showOpenFilePicker, showSaveFilePicker };
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe(useFileSystemAccess, () => {
it('reports support when the picker APIs exist', () => {
const { window } = stubWindow();
const scope = effectScope();
let fsa: UseFileSystemAccessReturn;
scope.run(() => {
fsa = useFileSystemAccess({ window });
});
expect(fsa!.isSupported.value).toBeTruthy();
scope.stop();
});
it('is not supported without the picker APIs', () => {
const window = {} as unknown as Window;
const scope = effectScope();
let fsa: UseFileSystemAccessReturn;
scope.run(() => {
fsa = useFileSystemAccess({ window });
});
expect(fsa!.isSupported.value).toBeFalsy();
scope.stop();
});
it('is not supported and is a no-op under SSR (no window)', async () => {
const scope = effectScope();
let fsa: UseFileSystemAccessReturn;
scope.run(() => {
fsa = useFileSystemAccess({ window: undefined });
});
expect(fsa!.isSupported.value).toBeFalsy();
await expect(fsa!.open()).resolves.toBeUndefined();
await expect(fsa!.save()).resolves.toBeUndefined();
expect(fsa!.fileName.value).toBe('');
expect(fsa!.fileSize.value).toBe(0);
expect(fsa!.data.value).toBeUndefined();
scope.stop();
});
it('opens a file and reads it as text by default', async () => {
const file = makeFile('content');
const { handle } = makeHandle(file);
const { window, showOpenFilePicker } = stubWindow(handle);
const scope = effectScope();
let fsa: UseFileSystemAccessReturn<string>;
scope.run(() => {
fsa = useFileSystemAccess({ window, dataType: 'Text' });
});
await fsa!.open();
expect(showOpenFilePicker).toHaveBeenCalledOnce();
expect(fsa!.data.value).toBe('content');
expect(fsa!.file.value).toBe(file);
expect(fsa!.fileName.value).toBe('demo.txt');
expect(fsa!.fileMIME.value).toBe('text/plain');
expect(fsa!.fileSize.value).toBe(7);
expect(fsa!.fileLastModified.value).toBe(1234);
scope.stop();
});
it('reads as ArrayBuffer when dataType is ArrayBuffer', async () => {
const file = makeFile('abc');
const { handle } = makeHandle(file);
const { window } = stubWindow(handle);
const scope = effectScope();
let fsa: UseFileSystemAccessReturn<ArrayBuffer>;
scope.run(() => {
fsa = useFileSystemAccess({ window, dataType: 'ArrayBuffer' });
});
await fsa!.open();
expect(fsa!.data.value).toBeInstanceOf(ArrayBuffer);
expect(file.arrayBuffer).toHaveBeenCalled();
scope.stop();
});
it('exposes the File itself when dataType is Blob', async () => {
const file = makeFile('abc');
const { handle } = makeHandle(file);
const { window } = stubWindow(handle);
const scope = effectScope();
let fsa: UseFileSystemAccessReturn<Blob>;
scope.run(() => {
fsa = useFileSystemAccess({ window, dataType: 'Blob' });
});
await fsa!.open();
expect(fsa!.data.value).toBe(file);
scope.stop();
});
it('passes types and excludeAcceptAllOption to the open picker', async () => {
const file = makeFile();
const { handle } = makeHandle(file);
const { window, showOpenFilePicker } = stubWindow(handle);
const types = [{ description: 'text', accept: { 'text/plain': ['.txt'] } }];
const scope = effectScope();
let fsa: UseFileSystemAccessReturn;
scope.run(() => {
fsa = useFileSystemAccess({ window, types, excludeAcceptAllOption: true });
});
await fsa!.open();
expect(showOpenFilePicker).toHaveBeenCalledWith({ types, excludeAcceptAllOption: true });
scope.stop();
});
it('lets per-call open options override the defaults', async () => {
const file = makeFile();
const { handle } = makeHandle(file);
const { window, showOpenFilePicker } = stubWindow(handle);
const scope = effectScope();
let fsa: UseFileSystemAccessReturn;
scope.run(() => {
fsa = useFileSystemAccess({ window, excludeAcceptAllOption: false });
});
await fsa!.open({ excludeAcceptAllOption: true });
expect(showOpenFilePicker).toHaveBeenCalledWith({ types: undefined, excludeAcceptAllOption: true });
scope.stop();
});
it('creates a new empty handle and clears prior data', async () => {
const file = makeFile('');
const { handle } = makeHandle(file);
const { window, showSaveFilePicker } = stubWindow(handle);
const scope = effectScope();
let fsa: UseFileSystemAccessReturn;
scope.run(() => {
fsa = useFileSystemAccess({ window });
});
await fsa!.create({ suggestedName: 'new.txt' });
expect(showSaveFilePicker).toHaveBeenCalledWith({ types: undefined, excludeAcceptAllOption: undefined, suggestedName: 'new.txt' });
expect(fsa!.file.value).toBe(file);
scope.stop();
});
it('save writes current data to the existing handle', async () => {
const file = makeFile('original');
const { handle, writable } = makeHandle(file);
const { window } = stubWindow(handle);
const scope = effectScope();
let fsa: UseFileSystemAccessReturn<string>;
scope.run(() => {
fsa = useFileSystemAccess({ window, dataType: 'Text' });
});
await fsa!.open();
fsa!.data.value = 'edited';
await fsa!.save();
expect(writable.write).toHaveBeenCalledWith('edited');
expect(writable.close).toHaveBeenCalledOnce();
scope.stop();
});
it('save falls back to saveAs when there is no handle', async () => {
const file = makeFile('');
const { handle, writable } = makeHandle(file);
const { window, showSaveFilePicker, showOpenFilePicker } = stubWindow(handle);
const scope = effectScope();
let fsa: UseFileSystemAccessReturn<string>;
scope.run(() => {
fsa = useFileSystemAccess({ window, dataType: 'Text' });
});
fsa!.data.value = 'fresh';
await fsa!.save();
expect(showOpenFilePicker).not.toHaveBeenCalled();
expect(showSaveFilePicker).toHaveBeenCalledOnce();
expect(writable.write).toHaveBeenCalledWith('fresh');
scope.stop();
});
it('saveAs requests a new handle and writes data', async () => {
const file = makeFile('');
const { handle, writable } = makeHandle(file);
const { window, showSaveFilePicker } = stubWindow(handle);
const scope = effectScope();
let fsa: UseFileSystemAccessReturn<string>;
scope.run(() => {
fsa = useFileSystemAccess({ window, dataType: 'Text' });
});
fsa!.data.value = 'payload';
await fsa!.saveAs({ suggestedName: 'out.txt' });
expect(showSaveFilePicker).toHaveBeenCalledWith({ types: undefined, excludeAcceptAllOption: undefined, suggestedName: 'out.txt' });
expect(writable.write).toHaveBeenCalledWith('payload');
scope.stop();
});
it('does not write when there is no data', async () => {
const file = makeFile('');
const { handle, writable } = makeHandle(file);
const { window } = stubWindow(handle);
const scope = effectScope();
let fsa: UseFileSystemAccessReturn<string>;
scope.run(() => {
fsa = useFileSystemAccess({ window, dataType: 'Text' });
});
await fsa!.saveAs();
expect(writable.write).not.toHaveBeenCalled();
scope.stop();
});
it('updateData re-reads the current file', async () => {
const file = makeFile('v1');
const { handle } = makeHandle(file);
const { window } = stubWindow(handle);
const scope = effectScope();
let fsa: UseFileSystemAccessReturn<string>;
scope.run(() => {
fsa = useFileSystemAccess({ window, dataType: 'Text' });
});
await fsa!.open();
expect(fsa!.data.value).toBe('v1');
(file.text as ReturnType<typeof vi.fn>).mockResolvedValueOnce('v2');
await fsa!.updateData();
expect(fsa!.data.value).toBe('v2');
scope.stop();
});
it('re-reads data when a reactive dataType changes', async () => {
const file = makeFile('reactive');
const { handle } = makeHandle(file);
const { window } = stubWindow(handle);
const dataType = ref<'Text' | 'Blob'>('Text');
const scope = effectScope();
let fsa: UseFileSystemAccessReturn;
scope.run(() => {
fsa = useFileSystemAccess({ window, dataType });
});
await fsa!.open();
expect(fsa!.data.value).toBe('reactive');
dataType.value = 'Blob';
await nextTick();
await Promise.resolve();
expect(fsa!.data.value).toBe(file);
scope.stop();
});
it('routes picker errors to onError instead of throwing', async () => {
const abort = new DOMException('cancelled', 'AbortError');
const window = {
showOpenFilePicker: vi.fn(async () => { throw abort; }),
showSaveFilePicker: vi.fn(async () => { throw abort; }),
} as unknown as Window;
const onError = vi.fn();
const scope = effectScope();
let fsa: UseFileSystemAccessReturn;
scope.run(() => {
fsa = useFileSystemAccess({ window, onError });
});
await expect(fsa!.open()).resolves.toBeUndefined();
expect(onError).toHaveBeenCalledWith(abort);
scope.stop();
});
});
@@ -0,0 +1,332 @@
import { computed, shallowRef, toValue, watch } from 'vue';
import type { ComputedRef, MaybeRefOrGetter, ShallowRef } from 'vue';
import { noop } from '@robonen/stdlib';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { useSupported } from '@/composables/utilities/useSupported';
/**
* `window.showOpenFilePicker` parameters.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Window/showOpenFilePicker#parameters
*/
export interface FileSystemAccessShowOpenFileOptions {
multiple?: boolean;
types?: Array<{
description?: string;
accept: Record<string, string[]>;
}>;
excludeAcceptAllOption?: boolean;
}
/**
* `window.showSaveFilePicker` parameters.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Window/showSaveFilePicker#parameters
*/
export interface FileSystemAccessShowSaveFileOptions {
suggestedName?: string;
types?: Array<{
description?: string;
accept: Record<string, string[]>;
}>;
excludeAcceptAllOption?: boolean;
}
/**
* @see https://developer.mozilla.org/en-US/docs/Web/API/FileSystemWritableFileStream/write
*/
export interface FileSystemWritableFileStreamWrite {
(data: string | BufferSource | Blob): Promise<void>;
(options: { type: 'write'; position: number; data: string | BufferSource | Blob }): Promise<void>;
(options: { type: 'seek'; position: number }): Promise<void>;
(options: { type: 'truncate'; size: number }): Promise<void>;
}
/**
* @see https://developer.mozilla.org/en-US/docs/Web/API/FileSystemWritableFileStream
*/
export interface FileSystemWritableFileStream extends WritableStream {
write: FileSystemWritableFileStreamWrite;
seek: (position: number) => Promise<void>;
truncate: (size: number) => Promise<void>;
}
/**
* @see https://developer.mozilla.org/en-US/docs/Web/API/FileSystemFileHandle
*/
export interface FileSystemFileHandle {
getFile: () => Promise<File>;
createWritable: () => Promise<FileSystemWritableFileStream>;
}
/**
* A `window` augmented with the File System Access API entry points.
*/
export type FileSystemAccessWindow
= Window & {
showSaveFilePicker: (options: FileSystemAccessShowSaveFileOptions) => Promise<FileSystemFileHandle>;
showOpenFilePicker: (options: FileSystemAccessShowOpenFileOptions) => Promise<FileSystemFileHandle[]>;
};
/**
* The supported file data types.
*/
export type UseFileSystemAccessDataType = 'Text' | 'ArrayBuffer' | 'Blob';
/**
* Picker options shared between open/create/save operations.
*/
export type UseFileSystemAccessCommonOptions
= Pick<FileSystemAccessShowOpenFileOptions, 'types' | 'excludeAcceptAllOption'>;
/**
* Picker options accepted by save-style operations.
*/
export type UseFileSystemAccessShowSaveFileOptions
= Pick<FileSystemAccessShowSaveFileOptions, 'suggestedName'> & UseFileSystemAccessCommonOptions;
export type UseFileSystemAccessOptions
= ConfigurableWindow & UseFileSystemAccessCommonOptions & {
/**
* How the file contents are read into `data`.
*
* @default 'Text'
*/
dataType?: MaybeRefOrGetter<UseFileSystemAccessDataType>;
/**
* Called when a picker or file operation fails.
*
* User-cancelled pickers reject with an `AbortError`; route them here to
* keep them out of the global unhandled-rejection channel.
*
* @default noop
*/
onError?: (error: unknown) => void;
};
export interface UseFileSystemAccessReturn<T = string | ArrayBuffer | Blob> {
/**
* Whether the File System Access API is available.
*/
isSupported: ComputedRef<boolean>;
/**
* The current file contents, read according to `dataType`.
*/
data: ShallowRef<T | undefined>;
/**
* The currently bound `File`, or `undefined` when no file is open.
*/
file: ShallowRef<File | undefined>;
/**
* The current file name (empty string when no file is open).
*/
fileName: ComputedRef<string>;
/**
* The current file MIME type (empty string when no file is open).
*/
fileMIME: ComputedRef<string>;
/**
* The current file size in bytes (`0` when no file is open).
*/
fileSize: ComputedRef<number>;
/**
* The current file's last-modified timestamp (`0` when no file is open).
*/
fileLastModified: ComputedRef<number>;
/**
* Show the open-file picker and load the chosen file.
*/
open: (options?: UseFileSystemAccessCommonOptions) => Promise<void>;
/**
* Show the save-file picker to create a new, empty file handle.
*/
create: (options?: UseFileSystemAccessShowSaveFileOptions) => Promise<void>;
/**
* Write `data` back to the current handle (falls back to `saveAs` when none).
*/
save: (options?: UseFileSystemAccessShowSaveFileOptions) => Promise<void>;
/**
* Show the save-file picker, then write `data` to the chosen handle.
*/
saveAs: (options?: UseFileSystemAccessShowSaveFileOptions) => Promise<void>;
/**
* Re-read `data` (and metadata) from the current handle.
*/
updateData: () => Promise<void>;
}
/**
* @name useFileSystemAccess
* @category Browser
* @description Create, read, and write local files via the File System Access API.
*
* @param {UseFileSystemAccessOptions} [options={}] Options including `dataType`, `types`, `excludeAcceptAllOption`, and `onError`
* @returns {UseFileSystemAccessReturn} `isSupported`, `data`, `file`, `fileName`, `fileMIME`, `fileSize`, `fileLastModified`, `open`, `create`, `save`, `saveAs`, `updateData`
*
* @example
* const { isSupported, data, open, save } = useFileSystemAccess({ dataType: 'Text' });
* await open();
* data.value += '\nappended';
* await save();
*
* @example
* // Read raw bytes
* const { data } = useFileSystemAccess({ dataType: 'ArrayBuffer' });
*
* @since 0.0.15
*/
export function useFileSystemAccess(): UseFileSystemAccessReturn<string | ArrayBuffer | Blob>;
export function useFileSystemAccess(options: UseFileSystemAccessOptions & { dataType: 'Text' }): UseFileSystemAccessReturn<string>;
export function useFileSystemAccess(options: UseFileSystemAccessOptions & { dataType: 'ArrayBuffer' }): UseFileSystemAccessReturn<ArrayBuffer>;
export function useFileSystemAccess(options: UseFileSystemAccessOptions & { dataType: 'Blob' }): UseFileSystemAccessReturn<Blob>;
export function useFileSystemAccess(options: UseFileSystemAccessOptions): UseFileSystemAccessReturn<string | ArrayBuffer | Blob>;
export function useFileSystemAccess(
options: UseFileSystemAccessOptions = {},
): UseFileSystemAccessReturn<string | ArrayBuffer | Blob> {
const {
window: win = defaultWindow,
dataType = 'Text',
types,
excludeAcceptAllOption,
onError = noop,
} = options;
const fsWindow = win as FileSystemAccessWindow | undefined;
const isSupported = useSupported(() => fsWindow && 'showSaveFilePicker' in fsWindow && 'showOpenFilePicker' in fsWindow);
const fileHandle = shallowRef<FileSystemFileHandle>();
const data = shallowRef<string | ArrayBuffer | Blob>();
const file = shallowRef<File>();
const fileName = computed(() => file.value?.name ?? '');
const fileMIME = computed(() => file.value?.type ?? '');
const fileSize = computed(() => file.value?.size ?? 0);
const fileLastModified = computed(() => file.value?.lastModified ?? 0);
// Resolve the picker defaults once per call rather than spreading the full
// options bag (which carries `window`/`onError`) into the native picker.
function pickerDefaults(): UseFileSystemAccessCommonOptions {
return { types, excludeAcceptAllOption };
}
async function updateFile(): Promise<void> {
file.value = await fileHandle.value?.getFile();
}
async function updateData(): Promise<void> {
await updateFile();
const type = toValue(dataType);
if (type === 'Text')
data.value = await file.value?.text();
else if (type === 'ArrayBuffer')
data.value = await file.value?.arrayBuffer();
else if (type === 'Blob')
data.value = file.value;
}
async function writeData(): Promise<void> {
if (!fileHandle.value || data.value === undefined || data.value === null)
return;
const stream = await fileHandle.value.createWritable();
await stream.write(data.value);
await stream.close();
}
async function open(_options: UseFileSystemAccessCommonOptions = {}): Promise<void> {
if (!isSupported.value || !fsWindow)
return;
try {
const [handle] = await fsWindow.showOpenFilePicker({ ...pickerDefaults(), ..._options });
if (!handle)
return;
fileHandle.value = handle;
await updateData();
}
catch (error) {
onError(error);
}
}
async function create(_options: UseFileSystemAccessShowSaveFileOptions = {}): Promise<void> {
if (!isSupported.value || !fsWindow)
return;
try {
fileHandle.value = await fsWindow.showSaveFilePicker({ ...pickerDefaults(), ..._options });
data.value = undefined;
await updateData();
}
catch (error) {
onError(error);
}
}
async function saveAs(_options: UseFileSystemAccessShowSaveFileOptions = {}): Promise<void> {
if (!isSupported.value || !fsWindow)
return;
try {
fileHandle.value = await fsWindow.showSaveFilePicker({ ...pickerDefaults(), ..._options });
await writeData();
await updateFile();
}
catch (error) {
onError(error);
}
}
async function save(_options: UseFileSystemAccessShowSaveFileOptions = {}): Promise<void> {
if (!isSupported.value)
return;
if (!fileHandle.value)
return saveAs(_options);
try {
await writeData();
await updateFile();
}
catch (error) {
onError(error);
}
}
// Re-read with the new strategy whenever `dataType` changes; skip the redundant
// initial run since nothing is open yet.
watch(() => toValue(dataType), () => {
if (fileHandle.value)
void updateData();
});
return {
isSupported,
data,
file,
fileName,
fileMIME,
fileSize,
fileLastModified,
open,
create,
save,
saveAs,
updateData,
};
}