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,224 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick } from 'vue';
import { useWakeLock } from '.';
import type { WakeLockSentinel, WakeLockType } from '.';
afterEach(() => {
vi.unstubAllGlobals();
});
interface FakeSentinel extends WakeLockSentinel {
dispatchRelease: () => void;
}
function createFakeSentinel(type: WakeLockType): FakeSentinel {
const target = new EventTarget();
const sentinel = target as unknown as FakeSentinel;
sentinel.type = type;
sentinel.released = false;
sentinel.release = vi.fn(async () => {
sentinel.released = true;
});
sentinel.dispatchRelease = () => {
sentinel.released = true;
target.dispatchEvent(new Event('release'));
};
return sentinel;
}
interface FakeEnv {
navigator: Navigator;
request: ReturnType<typeof vi.fn>;
sentinels: FakeSentinel[];
}
function createFakeNavigator(): FakeEnv {
const sentinels: FakeSentinel[] = [];
const request = vi.fn(async (type: WakeLockType) => {
const sentinel = createFakeSentinel(type);
sentinels.push(sentinel);
return sentinel;
});
const navigator = { wakeLock: { request } } as unknown as Navigator;
return { navigator, request, sentinels };
}
function createFakeDocument(state: DocumentVisibilityState = 'visible') {
const target = new EventTarget();
const doc = {
visibilityState: state,
addEventListener: target.addEventListener.bind(target),
removeEventListener: target.removeEventListener.bind(target),
} as unknown as Document & { visibilityState: DocumentVisibilityState };
const setVisibility = (next: DocumentVisibilityState) => {
doc.visibilityState = next;
target.dispatchEvent(new Event('visibilitychange'));
};
return { document: doc, setVisibility };
}
describe(useWakeLock, () => {
it('reports supported when navigator.wakeLock exists', () => {
const { navigator } = createFakeNavigator();
const scope = effectScope();
let api: ReturnType<typeof useWakeLock>;
scope.run(() => {
api = useWakeLock({ navigator });
});
expect(api!.isSupported.value).toBeTruthy();
expect(api!.isActive.value).toBeFalsy();
expect(api!.sentinel.value).toBeNull();
scope.stop();
});
it('reports unsupported when wakeLock is missing', () => {
const navigator = {} as Navigator;
const scope = effectScope();
let api: ReturnType<typeof useWakeLock>;
scope.run(() => {
api = useWakeLock({ navigator });
});
expect(api!.isSupported.value).toBeFalsy();
scope.stop();
});
it('requests a wake lock and becomes active', async () => {
const { navigator, request } = createFakeNavigator();
const { document } = createFakeDocument('visible');
const scope = effectScope();
let api: ReturnType<typeof useWakeLock>;
scope.run(() => {
api = useWakeLock({ navigator, document });
});
await api!.request('screen');
expect(request).toHaveBeenCalledWith('screen');
expect(api!.sentinel.value).not.toBeNull();
expect(api!.isActive.value).toBeTruthy();
scope.stop();
});
it('releases the wake lock and clears the sentinel', async () => {
const { navigator, sentinels } = createFakeNavigator();
const { document } = createFakeDocument('visible');
const scope = effectScope();
let api: ReturnType<typeof useWakeLock>;
scope.run(() => {
api = useWakeLock({ navigator, document });
});
await api!.request('screen');
await api!.release();
expect(sentinels[0]!.release).toHaveBeenCalledTimes(1);
expect(api!.sentinel.value).toBeNull();
expect(api!.isActive.value).toBeFalsy();
scope.stop();
});
it('forceRequest releases the previous lock before acquiring a new one', async () => {
const { navigator, sentinels, request } = createFakeNavigator();
const { document } = createFakeDocument('visible');
const scope = effectScope();
let api: ReturnType<typeof useWakeLock>;
scope.run(() => {
api = useWakeLock({ navigator, document });
});
await api!.forceRequest('screen');
await api!.forceRequest('screen');
expect(request).toHaveBeenCalledTimes(2);
expect(sentinels[0]!.release).toHaveBeenCalledTimes(1);
expect(api!.sentinel.value).toBe(sentinels[1]);
scope.stop();
});
it('defers the request while hidden and re-acquires on visible', async () => {
const { navigator, request } = createFakeNavigator();
const { document, setVisibility } = createFakeDocument('hidden');
const scope = effectScope();
let api: ReturnType<typeof useWakeLock>;
scope.run(() => {
api = useWakeLock({ navigator, document });
});
await api!.request('screen');
// Hidden: nothing acquired yet
expect(request).not.toHaveBeenCalled();
expect(api!.sentinel.value).toBeNull();
setVisibility('visible');
await nextTick();
await nextTick();
expect(request).toHaveBeenCalledWith('screen');
expect(api!.sentinel.value).not.toBeNull();
expect(api!.isActive.value).toBeTruthy();
scope.stop();
});
it('re-acquires after the browser releases the lock on the next visible transition', async () => {
const { navigator, sentinels, request } = createFakeNavigator();
const { document, setVisibility } = createFakeDocument('visible');
const scope = effectScope();
let api: ReturnType<typeof useWakeLock>;
scope.run(() => {
api = useWakeLock({ navigator, document });
});
await api!.request('screen');
expect(request).toHaveBeenCalledTimes(1);
// Browser auto-releases the sentinel (e.g. the tab was hidden)
setVisibility('hidden');
await nextTick();
sentinels[0]!.dispatchRelease();
await nextTick();
// Becoming visible again should re-acquire
setVisibility('visible');
await nextTick();
await nextTick();
expect(request).toHaveBeenCalledTimes(2);
scope.stop();
});
it('is SSR-safe with no navigator/document', async () => {
const scope = effectScope();
let api: ReturnType<typeof useWakeLock>;
scope.run(() => {
api = useWakeLock({ navigator: undefined, document: undefined });
});
expect(api!.isSupported.value).toBeFalsy();
// Should not throw on the unsupported path
await api!.request('screen');
expect(api!.sentinel.value).toBeNull();
expect(api!.isActive.value).toBeFalsy();
await api!.release();
scope.stop();
});
it('releases on scope dispose', async () => {
const { navigator, sentinels } = createFakeNavigator();
const { document } = createFakeDocument('visible');
const scope = effectScope();
let api: ReturnType<typeof useWakeLock>;
scope.run(() => {
api = useWakeLock({ navigator, document });
});
await api!.request('screen');
scope.stop();
await nextTick();
expect(sentinels[0]!.release).toHaveBeenCalled();
});
});
@@ -0,0 +1,143 @@
import { computed, shallowRef, watch } from 'vue';
import type { ComputedRef, ShallowRef } from 'vue';
import { defaultDocument, defaultNavigator } from '@/types';
import type { ConfigurableDocument, ConfigurableNavigator } from '@/types';
import { useEventListener } from '@/composables/browser/useEventListener';
import { useSupported } from '@/composables/utilities/useSupported';
import { useDocumentVisibility } from '@/composables/elements/useDocumentVisibility';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
export type WakeLockType = 'screen';
export interface WakeLockSentinel extends EventTarget {
type: WakeLockType;
released: boolean;
release: () => Promise<void>;
}
type NavigatorWithWakeLock
= Navigator & {
wakeLock: { request: (type: WakeLockType) => Promise<WakeLockSentinel> };
};
export type UseWakeLockOptions
= ConfigurableNavigator & ConfigurableDocument;
export interface UseWakeLockReturn {
/**
* Whether the Screen Wake Lock API is supported.
*/
isSupported: ComputedRef<boolean>;
/**
* The current `WakeLockSentinel`, or `null` when no lock is held.
*/
sentinel: ShallowRef<WakeLockSentinel | null>;
/**
* Whether a wake lock is currently held AND the document is visible.
*/
isActive: ComputedRef<boolean>;
/**
* Request a wake lock. If the document is hidden, the request is deferred
* and automatically (re)acquired once the document becomes visible again.
*/
request: (type: WakeLockType) => Promise<void>;
/**
* Request a wake lock immediately, releasing any existing one first.
* Will reject (via `onError`) if the document is hidden.
*/
forceRequest: (type: WakeLockType) => Promise<void>;
/**
* Release the current wake lock and cancel any deferred request.
*/
release: () => Promise<void>;
}
/**
* @name useWakeLock
* @category Browser
* @description Reactive wrapper over the Screen Wake Lock API to keep the screen awake.
* Re-acquires a deferred lock automatically when the document returns to visible.
*
* @param {UseWakeLockOptions} [options={}] Options (custom `navigator`, `document`)
* @returns {UseWakeLockReturn} `{ isSupported, sentinel, isActive, request, forceRequest, release }`
*
* @example
* const { isSupported, isActive, request, release } = useWakeLock();
* await request('screen');
* // ...later
* await release();
*
* @example
* // forceRequest re-acquires immediately, dropping any existing lock
* const { forceRequest } = useWakeLock();
* await forceRequest('screen');
*
* @since 0.0.15
*/
export function useWakeLock(options: UseWakeLockOptions = {}): UseWakeLockReturn {
const {
navigator = defaultNavigator,
document = defaultDocument,
} = options;
// Type to re-acquire once the document becomes visible again, or `false` when none pending.
const requestedType = shallowRef<WakeLockType | false>(false);
const sentinel = shallowRef<WakeLockSentinel | null>(null);
const visibility = useDocumentVisibility({ document });
const isSupported = useSupported(() => !!navigator && 'wakeLock' in navigator);
const isActive = computed(() => !!sentinel.value && visibility.value === 'visible');
async function forceRequest(type: WakeLockType): Promise<void> {
await sentinel.value?.release();
sentinel.value = isSupported.value
? await (navigator as NavigatorWithWakeLock).wakeLock.request(type)
: null;
}
async function request(type: WakeLockType): Promise<void> {
if (visibility.value === 'visible')
await forceRequest(type);
else
requestedType.value = type;
}
async function release(): Promise<void> {
requestedType.value = false;
const current = sentinel.value;
sentinel.value = null;
await current?.release();
}
if (isSupported.value) {
// The browser auto-releases the lock when the document is hidden;
// remember the type so we can re-acquire on the next visible transition.
useEventListener(sentinel, 'release', () => {
requestedType.value = sentinel.value?.type ?? false;
}, { passive: true });
watch(
() => visibility.value === 'visible' && requestedType.value,
(type) => {
if (!type)
return;
requestedType.value = false;
forceRequest(type);
},
);
}
tryOnScopeDispose(() => {
release();
});
return {
isSupported,
sentinel,
isActive,
request,
forceRequest,
release,
};
}