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