feat(vue): expand @robonen/vue composable collection
Composables, tests, category barrels, and README for @robonen/vue.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user