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,254 @@
import { effectScope, nextTick, ref } from 'vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useImage } from '.';
interface FakeImage {
src: string;
srcset: string;
sizes: string;
alt: string;
className: string;
loading: string;
crossOrigin: string | null;
referrerPolicy: string;
width: number;
height: number;
decoding: string;
fetchPriority: string;
isMap: boolean;
useMap: string;
onload: (() => void) | null;
onerror: ((err: unknown) => void) | null;
}
let lastImage: FakeImage | undefined;
// Decides whether the next constructed image "loads" or "errors".
let shouldFail = false;
function createImage(): FakeImage {
const img: FakeImage = {
src: '',
srcset: '',
sizes: '',
alt: '',
className: '',
loading: '',
crossOrigin: null,
referrerPolicy: '',
width: 0,
height: 0,
decoding: '',
fetchPriority: '',
isMap: false,
useMap: '',
onload: null,
onerror: null,
};
lastImage = img;
// Mimic the browser firing load/error asynchronously after src is set.
queueMicrotask(() => {
if (shouldFail)
img.onerror?.(new Error('load-error'));
else
img.onload?.();
});
return img;
}
function createFakeWindow(): Window {
const Image = function Image(): FakeImage {
return createImage();
} as unknown as new () => HTMLImageElement;
return { Image } as unknown as Window;
}
describe(useImage, () => {
let window: Window;
beforeEach(() => {
lastImage = undefined;
shouldFail = false;
window = createFakeWindow();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('loads an image and exposes the element as state', async () => {
const scope = effectScope();
let result!: ReturnType<typeof useImage>;
scope.run(() => {
result = useImage({ src: '/cat.png' }, { window });
});
expect(result.isLoading.value).toBeTruthy();
expect(result.isReady.value).toBeFalsy();
expect(result.error.value).toBe(null);
await nextTick();
await nextTick();
expect(result.isLoading.value).toBeFalsy();
expect(result.isReady.value).toBeTruthy();
expect(result.error.value).toBe(null);
expect(result.state.value).toBe(lastImage);
expect(lastImage?.src).toBe('/cat.png');
scope.stop();
});
it('applies the provided image attributes', async () => {
const scope = effectScope();
scope.run(() => {
useImage(
{
src: '/cat.png',
srcset: '/cat-2x.png 2x',
sizes: '100vw',
alt: 'a cat',
class: 'rounded',
loading: 'lazy',
crossorigin: 'anonymous',
referrerPolicy: 'no-referrer',
width: 320,
height: 240,
decoding: 'async',
fetchPriority: 'high',
ismap: true,
usemap: '#map',
},
{ window },
);
});
await nextTick();
await nextTick();
expect(lastImage).toBeDefined();
expect(lastImage!.src).toBe('/cat.png');
expect(lastImage!.srcset).toBe('/cat-2x.png 2x');
expect(lastImage!.sizes).toBe('100vw');
expect(lastImage!.alt).toBe('a cat');
expect(lastImage!.className).toBe('rounded');
expect(lastImage!.loading).toBe('lazy');
expect(lastImage!.crossOrigin).toBe('anonymous');
expect(lastImage!.referrerPolicy).toBe('no-referrer');
expect(lastImage!.width).toBe(320);
expect(lastImage!.height).toBe(240);
expect(lastImage!.decoding).toBe('async');
expect(lastImage!.fetchPriority).toBe('high');
expect(lastImage!.isMap).toBeTruthy();
expect(lastImage!.useMap).toBe('#map');
scope.stop();
});
it('captures load errors', async () => {
shouldFail = true;
const scope = effectScope();
let result!: ReturnType<typeof useImage>;
scope.run(() => {
result = useImage({ src: '/missing.png' }, { window });
});
await nextTick();
await nextTick();
expect(result.isLoading.value).toBeFalsy();
expect(result.isReady.value).toBeFalsy();
expect(result.error.value).toBeInstanceOf(Error);
expect(result.state.value).toBe(undefined);
scope.stop();
});
it('reloads when a reactive source changes', async () => {
const src = ref('/a.png');
const scope = effectScope();
let result!: ReturnType<typeof useImage>;
scope.run(() => {
result = useImage(() => ({ src: src.value }), { window });
});
await nextTick();
await nextTick();
expect(lastImage?.src).toBe('/a.png');
expect(result.isReady.value).toBeTruthy();
src.value = '/b.png';
await nextTick();
// resetOnExecute clears state and flips loading back on
expect(result.isLoading.value).toBeTruthy();
expect(result.state.value).toBe(undefined);
await nextTick();
await nextTick();
expect(lastImage?.src).toBe('/b.png');
expect(result.isReady.value).toBeTruthy();
scope.stop();
});
it('does not set up a watcher for a plain options object', async () => {
const watchSpy = vi.fn();
const scope = effectScope();
scope.run(() => {
const result = useImage({ src: '/static.png' }, { window });
// execute is the only thing a reload would call; spy after initial run
result.execute = watchSpy;
});
await nextTick();
await nextTick();
expect(watchSpy).not.toHaveBeenCalled();
scope.stop();
});
it('does not call execute immediately when immediate is false', async () => {
const scope = effectScope();
let result!: ReturnType<typeof useImage>;
scope.run(() => {
result = useImage({ src: '/cat.png' }, { window, immediate: false });
});
expect(result.isLoading.value).toBeFalsy();
expect(lastImage).toBeUndefined();
await result.execute();
expect(lastImage?.src).toBe('/cat.png');
expect(result.isReady.value).toBeTruthy();
scope.stop();
});
it('rejects when no Image constructor is available (SSR path)', async () => {
const onError = vi.fn();
// A window without an Image constructor stands in for a non-DOM environment.
const ssrWindow = {} as unknown as Window;
const scope = effectScope();
let result!: ReturnType<typeof useImage>;
scope.run(() => {
result = useImage({ src: '/cat.png' }, { window: ssrWindow, onError });
});
await nextTick();
await nextTick();
expect(result.isReady.value).toBeFalsy();
expect(result.error.value).toBeInstanceOf(Error);
expect(onError).toHaveBeenCalledTimes(1);
scope.stop();
});
});
@@ -0,0 +1,144 @@
import { isRef, toValue, watch } from 'vue';
import type { MaybeRefOrGetter } from 'vue';
import { isFunction } from '@robonen/stdlib';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { useAsyncState } from '@/composables/state/useAsyncState';
import type { UseAsyncStateOptions, UseAsyncStateReturn } from '@/composables/state/useAsyncState';
export interface UseImageOptions {
/** Address of the resource */
src: string;
/** Images to use in different situations, e.g. high-resolution displays, small monitors, etc. */
srcset?: string;
/** Image sizes for different page layouts */
sizes?: string;
/** Image alternative information */
alt?: string;
/** Image classes */
class?: string;
/** Image loading strategy */
loading?: HTMLImageElement['loading'];
/** Image CORS settings */
crossorigin?: string;
/** Referrer policy for fetch — https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy */
referrerPolicy?: HTMLImageElement['referrerPolicy'];
/** Image width */
width?: HTMLImageElement['width'];
/** Image height */
height?: HTMLImageElement['height'];
/** Image decoding hint — https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#decoding */
decoding?: HTMLImageElement['decoding'];
/** Relative priority hint for fetching the image */
fetchPriority?: HTMLImageElement['fetchPriority'];
/** Whether the image is a server-side image map */
ismap?: HTMLImageElement['isMap'];
/** Partial URL (starting with #) of an image map associated with the element */
usemap?: HTMLImageElement['useMap'];
}
export interface UseImageAsyncStateOptions
extends UseAsyncStateOptions<true, HTMLImageElement | undefined>, ConfigurableWindow {}
export type UseImageReturn = UseAsyncStateReturn<HTMLImageElement | undefined, any[], true>;
interface LoadImageContext {
window?: Window;
}
function loadImage(options: UseImageOptions, ctx: LoadImageContext): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
// `Image` is a global constructor on `typeof globalThis`, not the `Window` interface.
const ImageCtor = (ctx.window as (Window & typeof globalThis) | undefined)?.Image;
if (!ImageCtor) {
reject(new Error('useImage: no Image constructor available (are you running on the server?)'));
return;
}
const img = new ImageCtor();
const {
src,
srcset,
sizes,
alt,
class: className,
loading,
crossorigin,
referrerPolicy,
width,
height,
decoding,
fetchPriority,
ismap,
usemap,
} = options;
if (alt !== undefined) img.alt = alt;
if (className !== undefined) img.className = className;
if (loading !== undefined) img.loading = loading;
if (crossorigin !== undefined) img.crossOrigin = crossorigin;
if (referrerPolicy !== undefined) img.referrerPolicy = referrerPolicy;
if (width !== undefined) img.width = width;
if (height !== undefined) img.height = height;
if (decoding !== undefined) img.decoding = decoding;
if (fetchPriority !== undefined) img.fetchPriority = fetchPriority;
if (ismap !== undefined) img.isMap = ismap;
if (usemap !== undefined) img.useMap = usemap;
// Setting srcset/sizes before src lets the browser pick the right candidate up-front.
if (sizes !== undefined) img.sizes = sizes;
if (srcset !== undefined) img.srcset = srcset;
img.src = src;
img.onload = () => resolve(img);
img.onerror = reject;
});
}
/**
* @name useImage
* @category Browser
* @description Reactively load an image in the browser; await the result to render it or show a fallback.
*
* @param {MaybeRefOrGetter<UseImageOptions>} options Image attributes (as used on the `<img>` tag); pass a ref/getter to reload reactively
* @param {UseImageAsyncStateOptions} [asyncStateOptions={}] `useAsyncState` options (`delay`, `immediate`, `onError`, …) plus a configurable `window`
* @returns {UseImageReturn} `useAsyncState`-shaped `{ isLoading, isReady, error, state, execute, … }` for an `HTMLImageElement`
*
* @example
* const { isLoading, error, state: image } = useImage({ src: '/cat.png' });
*
* @example
* // Reactive source: reloads whenever `src` changes
* const src = ref('/a.png');
* const { state } = useImage(() => ({ src: src.value, alt: 'photo' }));
*
* @since 0.0.15
*/
export function useImage(
options: MaybeRefOrGetter<UseImageOptions>,
asyncStateOptions: UseImageAsyncStateOptions = {},
): UseImageReturn {
const { window = defaultWindow, ...stateOptions } = asyncStateOptions;
const state = useAsyncState<HTMLImageElement | undefined>(
() => loadImage(toValue(options), { window }),
undefined,
{
resetOnExecute: true,
...stateOptions,
},
);
// A plain (non-ref, non-getter) options object can never change, so we skip
// the watcher entirely — no needless deep traversal on every tick.
if (isRef(options) || isFunction(options)) {
watch(
() => toValue(options),
() => state.execute(stateOptions.delay),
{ deep: true },
);
}
return state;
}