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,179 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick } from 'vue';
import { useDocumentPiP } from '.';
import type { DocumentPictureInPictureOptions } from '.';
/**
* Build a fake Picture-in-Picture `Window` that records its listeners and
* `close()` call. Passed through options so it reaches the import-time-captured
* `defaultWindow` substitute (see test gotcha).
*/
function createPipWindow() {
const listeners = new Map<string, EventListener>();
const pip = {
addEventListener: vi.fn((type: string, listener: EventListener) => {
listeners.set(type, listener);
}),
close: vi.fn(),
} as unknown as Window;
function firePagehide() {
listeners.get('pagehide')?.(new Event('pagehide'));
}
return { pip, firePagehide, close: pip.close as ReturnType<typeof vi.fn> };
}
function createWindow(pip: Window) {
const requestWindow = vi.fn(async (_options?: DocumentPictureInPictureOptions) => pip);
const win = {
documentPictureInPicture: { window: null, requestWindow },
} as unknown as Window & typeof globalThis;
return { window: win, requestWindow };
}
describe(useDocumentPiP, () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('reports supported when documentPictureInPicture exists on window', () => {
const scope = effectScope();
const { pip } = createPipWindow();
const { window } = createWindow(pip);
let result: ReturnType<typeof useDocumentPiP>;
scope.run(() => {
result = useDocumentPiP({ window });
});
expect(result!.isSupported.value).toBeTruthy();
scope.stop();
});
it('reports unsupported when the API is absent', () => {
const scope = effectScope();
const win = {} as unknown as Window & typeof globalThis;
let result: ReturnType<typeof useDocumentPiP>;
scope.run(() => {
result = useDocumentPiP({ window: win });
});
expect(result!.isSupported.value).toBeFalsy();
scope.stop();
});
it('is SSR safe when window is undefined', async () => {
const scope = effectScope();
let result: ReturnType<typeof useDocumentPiP>;
scope.run(() => {
result = useDocumentPiP({ window: undefined as unknown as Window });
});
expect(result!.isSupported.value).toBeFalsy();
await expect(result!.open()).resolves.toBeUndefined();
scope.stop();
});
it('opens a PiP window, tracks it, and forwards options', async () => {
const scope = effectScope();
const { pip } = createPipWindow();
const { window, requestWindow } = createWindow(pip);
let result: ReturnType<typeof useDocumentPiP>;
scope.run(() => {
result = useDocumentPiP({ window });
});
const returned = await result!.open({ width: 320, height: 240 });
await nextTick();
expect(requestWindow).toHaveBeenCalledWith({ width: 320, height: 240 });
expect(returned).toBe(pip);
expect(result!.pipWindow.value).toBe(pip);
expect(result!.isOpen.value).toBeTruthy();
scope.stop();
});
it('clears the reference when the PiP window emits pagehide', async () => {
const scope = effectScope();
const { pip, firePagehide } = createPipWindow();
const { window } = createWindow(pip);
let result: ReturnType<typeof useDocumentPiP>;
scope.run(() => {
result = useDocumentPiP({ window });
});
await result!.open();
expect(result!.isOpen.value).toBeTruthy();
firePagehide();
await nextTick();
expect(result!.pipWindow.value).toBeNull();
expect(result!.isOpen.value).toBeFalsy();
scope.stop();
});
it('close() closes the window and clears state', async () => {
const scope = effectScope();
const { pip, close } = createPipWindow();
const { window } = createWindow(pip);
let result: ReturnType<typeof useDocumentPiP>;
scope.run(() => {
result = useDocumentPiP({ window });
});
await result!.open();
result!.close();
expect(close).toHaveBeenCalledTimes(1);
expect(result!.pipWindow.value).toBeNull();
scope.stop();
});
it('closes the PiP window when the scope is disposed', async () => {
const scope = effectScope();
const { pip, close } = createPipWindow();
const { window } = createWindow(pip);
let result: ReturnType<typeof useDocumentPiP>;
scope.run(() => {
result = useDocumentPiP({ window });
});
await result!.open();
scope.stop();
expect(close).toHaveBeenCalledTimes(1);
});
it('stores the error and invokes onError when open() rejects', async () => {
const scope = effectScope();
const error = new DOMException('gesture required', 'NotAllowedError');
const requestWindow = vi.fn(async () => {
throw error;
});
const win = {
documentPictureInPicture: { window: null, requestWindow },
} as unknown as Window & typeof globalThis;
const onError = vi.fn();
let result: ReturnType<typeof useDocumentPiP>;
scope.run(() => {
result = useDocumentPiP({ window: win, onError });
});
await expect(result!.open()).resolves.toBeUndefined();
expect(onError).toHaveBeenCalledWith(error);
expect(result!.error.value).toBe(error);
expect(result!.isOpen.value).toBeFalsy();
scope.stop();
});
});
@@ -0,0 +1,169 @@
import { computed, shallowRef } from 'vue';
import type { ComputedRef, ShallowRef } from 'vue';
import { noop } from '@robonen/stdlib';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { useSupported } from '@/composables/utilities/useSupported';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
export interface DocumentPictureInPictureOptions {
/**
* The initial width of the Picture-in-Picture window, in pixels.
*/
width?: number;
/**
* The initial height of the Picture-in-Picture window, in pixels.
*/
height?: number;
/**
* Hide the "back to tab" button in the Picture-in-Picture window.
*
* @default false
*/
disallowReturnToOpener?: boolean;
/**
* Open the window in its default position/size rather than reusing the last one.
*
* @default false
*/
preferInitialWindowPlacement?: boolean;
}
interface DocumentPictureInPicture {
readonly window: Window | null;
requestWindow: (options?: DocumentPictureInPictureOptions) => Promise<Window>;
}
interface WindowWithDocumentPiP {
documentPictureInPicture: DocumentPictureInPicture;
}
export interface UseDocumentPiPOptions extends ConfigurableWindow {
/**
* Called when `open()` rejects (e.g. not triggered by a user gesture) instead
* of throwing. The same value is also stored in the returned `error` ref.
*
* @default noop
*/
onError?: (error: unknown) => void;
}
export interface UseDocumentPiPReturn {
/**
* Whether the [Document Picture-in-Picture API](https://developer.mozilla.org/en-US/docs/Web/API/DocumentPictureInPicture) is supported
*/
isSupported: ComputedRef<boolean>;
/**
* The active Picture-in-Picture `Window`, or `null` when none is open
*/
pipWindow: ShallowRef<Window | null>;
/**
* Whether a Picture-in-Picture window is currently open
*/
isOpen: ComputedRef<boolean>;
/**
* The last error thrown by `open()`, or `null`
*/
error: ShallowRef<unknown | null>;
/**
* Open a Picture-in-Picture window. Must be called from a user gesture.
* Resolves with the new `Window`, or `undefined` when unsupported.
*/
open: (pipOptions?: DocumentPictureInPictureOptions) => Promise<Window | undefined>;
/**
* Close the active Picture-in-Picture window, if any
*/
close: () => void;
}
/**
* @name useDocumentPiP
* @category Browser
* @description Reactive wrapper around the [Document Picture-in-Picture API](https://developer.mozilla.org/en-US/docs/Web/API/DocumentPictureInPicture) for rendering arbitrary DOM in an always-on-top window.
*
* @param {UseDocumentPiPOptions} [options={}] Options
* @param {Function} [options.onError=noop] Error callback invoked instead of throwing
* @param {Window} [options.window=defaultWindow] Custom `window` instance
* @returns {UseDocumentPiPReturn} `isSupported`, `pipWindow`, `isOpen`, `error`, `open()`, and `close()`
*
* @example
* const { isSupported, pipWindow, open } = useDocumentPiP();
* async function popOut(content: HTMLElement) {
* const win = await open({ width: 320, height: 240 });
* win?.document.body.append(content);
* }
*
* @example
* // Move a player into the PiP window and track open state
* const { isOpen, pipWindow, open, close } = useDocumentPiP();
* watchEffect(() => {
* if (pipWindow.value)
* pipWindow.value.document.body.append(playerEl);
* });
*
* @since 0.0.15
*/
export function useDocumentPiP(options: UseDocumentPiPOptions = {}): UseDocumentPiPReturn {
const {
window = defaultWindow,
onError = noop,
} = options;
const isSupported = useSupported(() => !!window && 'documentPictureInPicture' in window);
const pipWindow = shallowRef<Window | null>(null);
const error = shallowRef<unknown | null>(null);
const isOpen = computed<boolean>(() => pipWindow.value !== null);
function handleClose(): void {
pipWindow.value = null;
}
async function open(pipOptions?: DocumentPictureInPictureOptions): Promise<Window | undefined> {
if (!isSupported.value || !window)
return undefined;
error.value = null;
try {
const controller = (window as unknown as WindowWithDocumentPiP).documentPictureInPicture;
const pip = await controller.requestWindow(pipOptions);
// The PiP window closing (user or programmatic) clears our reference.
pip.addEventListener('pagehide', handleClose, { once: true });
pipWindow.value = pip;
return pip;
}
catch (err) {
error.value = err;
onError(err);
return undefined;
}
}
function close(): void {
pipWindow.value?.close();
pipWindow.value = null;
}
tryOnScopeDispose(close);
return {
isSupported,
pipWindow,
isOpen,
error,
open,
close,
};
}