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,147 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { defineComponent, effectScope, nextTick, watch } from 'vue';
import { mount } from '@vue/test-utils';
import { broadcastedRef } from '.';
type MessageHandler = ((event: MessageEvent) => void) | null;
class MockBroadcastChannel {
static instances: MockBroadcastChannel[] = [];
name: string;
onmessage: MessageHandler = null;
closed = false;
constructor(name: string) {
this.name = name;
MockBroadcastChannel.instances.push(this);
}
postMessage(data: unknown) {
if (this.closed) return;
for (const instance of MockBroadcastChannel.instances) {
if (instance !== this && instance.name === this.name && !instance.closed && instance.onmessage) {
instance.onmessage(new MessageEvent('message', { data }));
}
}
}
close() {
this.closed = true;
const index = MockBroadcastChannel.instances.indexOf(this);
if (index > -1) MockBroadcastChannel.instances.splice(index, 1);
}
}
const mountWithRef = (setup: () => Record<string, any> | void) => {
return mount(
defineComponent({
setup,
template: '<div></div>',
}),
);
};
describe(broadcastedRef, () => {
let component: ReturnType<typeof mountWithRef>;
beforeEach(() => {
MockBroadcastChannel.instances = [];
vi.stubGlobal('BroadcastChannel', MockBroadcastChannel);
});
afterEach(() => {
component?.unmount();
vi.unstubAllGlobals();
});
it('create a ref with the initial value', () => {
component = mountWithRef(() => {
const count = broadcastedRef('test-key', 42);
expect(count.value).toBe(42);
});
});
it('broadcast value changes to other channels with the same key', () => {
const ref1 = broadcastedRef('shared', 0);
const ref2 = broadcastedRef('shared', 0);
ref1.value = 100;
expect(ref2.value).toBe(100);
});
it('not broadcast to channels with a different key', () => {
const ref1 = broadcastedRef('key-a', 0);
const ref2 = broadcastedRef('key-b', 0);
ref1.value = 100;
expect(ref2.value).toBe(0);
});
it('receive values from other channels and trigger reactivity', async () => {
const callback = vi.fn();
component = mountWithRef(() => {
const data = broadcastedRef('reactive-test', 'initial');
watch(data, callback, { flush: 'sync' });
});
const sender = broadcastedRef('reactive-test', '');
sender.value = 'updated';
expect(callback).toHaveBeenCalledOnce();
expect(callback).toHaveBeenCalledWith('updated', 'initial', expect.anything());
});
it('not broadcast initial value by default', () => {
const ref1 = broadcastedRef('no-immediate', 'first');
const ref2 = broadcastedRef('no-immediate', 'second');
expect(ref1.value).toBe('first');
expect(ref2.value).toBe('second');
});
it('broadcast initial value when immediate is true', () => {
const ref1 = broadcastedRef('immediate-test', 'existing');
broadcastedRef('immediate-test', 'new-value', { immediate: true });
expect(ref1.value).toBe('new-value');
});
it('close channel on scope dispose', () => {
const scope = effectScope();
scope.run(() => {
broadcastedRef('dispose-test', 0);
});
expect(MockBroadcastChannel.instances).toHaveLength(1);
scope.stop();
expect(MockBroadcastChannel.instances).toHaveLength(0);
});
it('handle complex object values via structured clone', () => {
const ref1 = broadcastedRef('object-test', { status: 'pending', amount: 0 });
const ref2 = broadcastedRef('object-test', { status: 'pending', amount: 0 });
ref1.value = { status: 'paid', amount: 99.99 };
expect(ref2.value).toEqual({ status: 'paid', amount: 99.99 });
});
it('fallback to a regular ref when BroadcastChannel is not available', () => {
vi.stubGlobal('BroadcastChannel', undefined);
const data = broadcastedRef('fallback', 'value');
expect(data.value).toBe('value');
data.value = 'updated';
expect(data.value).toBe('updated');
});
});
@@ -0,0 +1,68 @@
import { customRef, ref } from 'vue';
import type { Ref } from 'vue';
import { defaultWindow } from '@/types';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
export interface BroadcastedRefOptions {
/**
* Immediately broadcast the initial value to other tabs on creation
* @default false
*/
immediate?: boolean;
}
/**
* @name broadcastedRef
* @category Browser
* @description Creates a custom ref that syncs its value across browser tabs via the BroadcastChannel API
*
* @param {string} key The channel key to use for broadcasting
* @param {T} initialValue The initial value of the ref
* @param {BroadcastedRefOptions} [options={}] Options
* @returns {Ref<T>} A custom ref that broadcasts value changes across tabs
*
* @example
* const count = broadcastedRef('counter', 0);
*
* @example
* const state = broadcastedRef('payment-status', { status: 'pending' });
*
* @since 0.0.13
*/
export function broadcastedRef<T>(key: string, initialValue: T, options: BroadcastedRefOptions = {}): Ref<T> {
const { immediate = false } = options;
if (!defaultWindow || typeof BroadcastChannel === 'undefined') {
return ref(initialValue) as Ref<T>;
}
const channel = new BroadcastChannel(key);
let value = initialValue;
const data = customRef<T>((track, trigger) => {
channel.onmessage = (event: MessageEvent<T>) => {
value = event.data;
trigger();
};
return {
get() {
track();
return value;
},
set(newValue: T) {
value = newValue;
channel.postMessage(newValue);
trigger();
},
};
});
if (immediate) {
channel.postMessage(initialValue);
}
tryOnScopeDispose(() => channel.close());
return data;
}
+19 -41
View File
@@ -1,58 +1,36 @@
export * from './onKeyStroke';
export * from './useActiveElement';
export * from './useBodyScrollLock';
export * from './useClickOutside';
export * from './broadcastedRef';
export * from './useBreakpoints';
export * from './useClipboard';
export * from './useClipboardItems';
export * from './useCloseWatcher';
export * from './useColorMode';
export * from './useDevicePixelRatio';
export * from './useDocumentReadyState';
export * from './useDocumentVisibility';
export * from './useDropZone';
export * from './useElementBounding';
export * from './useElementHover';
export * from './useElementSize';
export * from './useElementVisibility';
export * from './useEscapeKey';
export * from './useCssVar';
export * from './useDark';
export * from './useDocumentPiP';
export * from './useEventListener';
export * from './useEyeDropper';
export * from './useFavicon';
export * from './useFileDialog';
export * from './useFocus';
export * from './useFocusGuard';
export * from './useFocusWithin';
export * from './useFps';
export * from './useFileSystemAccess';
export * from './useFullscreen';
export * from './useGeolocation';
export * from './useIdle';
export * from './useIntersectionObserver';
export * from './useIntervalFn';
export * from './useKeyModifier';
export * from './useMagicKeys';
export * from './useImage';
export * from './useLocalFonts';
export * from './useMediaQuery';
export * from './useMouse';
export * from './useMousePressed';
export * from './useMutationObserver';
export * from './useNetwork';
export * from './useObjectUrl';
export * from './useOnline';
export * from './usePageLeave';
export * from './usePermission';
export * from './usePointer';
export * from './usePreferredColorScheme';
export * from './usePreferredContrast';
export * from './usePreferredDark';
export * from './useRafFn';
export * from './useResizeObserver';
export * from './useScreenOrientation';
export * from './useScroll';
export * from './useScrollLock';
export * from './usePreferredLanguages';
export * from './usePreferredReducedMotion';
export * from './usePreferredReducedTransparency';
export * from './useScriptTag';
export * from './useShare';
export * from './useSupported';
export * from './useSwipe';
export * from './useStyleTag';
export * from './useTabLeader';
export * from './useTextSelection';
export * from './useTextareaAutosize';
export * from './useTitle';
export * from './useUrlSearchParams';
export * from './useVibrate';
export * from './useWindowFocus';
export * from './useWindowScroll';
export * from './useWindowSize';
export * from './useWakeLock';
export * from './useWebNotification';
@@ -1,237 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { effectScope, ref } from 'vue';
import { onKeyDown, onKeyPressed, onKeyStroke, onKeyUp } from '.';
function keydown(key: string, init: KeyboardEventInit = {}, target: EventTarget = globalThis) {
target.dispatchEvent(new KeyboardEvent('keydown', { key, ...init }));
}
describe(onKeyStroke, () => {
let stops: Array<() => void> = [];
function track(stop: () => void) {
stops.push(stop);
return stop;
}
afterEach(() => {
stops.forEach(stop => stop());
stops = [];
});
it('fires the handler for a matching string key', () => {
const handler = vi.fn();
track(onKeyStroke('a', handler));
keydown('a');
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0]![0]).toBeInstanceOf(KeyboardEvent);
});
it('ignores non-matching keys', () => {
const handler = vi.fn();
track(onKeyStroke('a', handler));
keydown('b');
expect(handler).not.toHaveBeenCalled();
});
it('matches any key in an array filter', () => {
const handler = vi.fn();
track(onKeyStroke(['a', 'b', 'c'], handler));
keydown('a');
keydown('b');
keydown('z');
expect(handler).toHaveBeenCalledTimes(2);
});
it('uses a predicate filter', () => {
const handler = vi.fn();
track(onKeyStroke((e: KeyboardEvent) => e.metaKey && e.key === 's', handler));
keydown('s');
expect(handler).not.toHaveBeenCalled();
keydown('s', { metaKey: true });
expect(handler).toHaveBeenCalledTimes(1);
});
it('matches every key when filter is omitted', () => {
const handler = vi.fn();
track(onKeyStroke(handler));
keydown('a');
keydown('b');
expect(handler).toHaveBeenCalledTimes(2);
});
it('matches every key when filter is `true`', () => {
const handler = vi.fn();
track(onKeyStroke(true, handler));
keydown('x');
expect(handler).toHaveBeenCalledTimes(1);
});
it('supports the handler-plus-options overload', () => {
const handler = vi.fn();
const target = document.createElement('div');
track(onKeyStroke(handler, { target }));
keydown('a', {}, target);
expect(handler).toHaveBeenCalledTimes(1);
keydown('a');
expect(handler).toHaveBeenCalledTimes(1);
});
it('attaches to a custom target', () => {
const handler = vi.fn();
const target = document.createElement('div');
track(onKeyStroke('a', handler, { target }));
keydown('a', {}, target);
expect(handler).toHaveBeenCalledTimes(1);
keydown('a');
expect(handler).toHaveBeenCalledTimes(1);
});
it('listens on a custom eventName', () => {
const handler = vi.fn();
const target = document.createElement('div');
track(onKeyStroke('a', handler, { target, eventName: 'keyup' }));
keydown('a', {}, target);
expect(handler).not.toHaveBeenCalled();
target.dispatchEvent(new KeyboardEvent('keyup', { key: 'a' }));
expect(handler).toHaveBeenCalledTimes(1);
});
it('dedupe ignores auto-repeated events', () => {
const handler = vi.fn();
track(onKeyStroke('a', handler, { dedupe: true }));
keydown('a', { repeat: false });
keydown('a', { repeat: true });
keydown('a', { repeat: true });
expect(handler).toHaveBeenCalledTimes(1);
});
it('dedupe accepts a reactive ref', () => {
const handler = vi.fn();
const dedupe = ref(false);
track(onKeyStroke('a', handler, { dedupe }));
keydown('a', { repeat: true });
expect(handler).toHaveBeenCalledTimes(1);
dedupe.value = true;
keydown('a', { repeat: true });
expect(handler).toHaveBeenCalledTimes(1);
});
it('without dedupe still fires for repeated events', () => {
const handler = vi.fn();
track(onKeyStroke('a', handler));
keydown('a', { repeat: true });
keydown('a', { repeat: true });
expect(handler).toHaveBeenCalledTimes(2);
});
it('stop handle removes the listener', () => {
const handler = vi.fn();
const stop = onKeyStroke('a', handler);
stop();
keydown('a');
expect(handler).not.toHaveBeenCalled();
});
it('cleans up when the effect scope is disposed', () => {
const handler = vi.fn();
const scope = effectScope();
scope.run(() => {
onKeyStroke('a', handler);
});
scope.stop();
keydown('a');
expect(handler).not.toHaveBeenCalled();
});
it('returns a no-op when target is unavailable (SSR / unsupported)', () => {
const handler = vi.fn();
const stop = onKeyStroke('a', handler, { target: null });
expect(typeof stop).toBe('function');
keydown('a');
expect(handler).not.toHaveBeenCalled();
// stop should be safely callable
expect(() => stop()).not.toThrow();
});
});
describe(onKeyDown, () => {
let stop: (() => void) | undefined;
afterEach(() => {
stop?.();
stop = undefined;
});
it('listens for keydown', () => {
const handler = vi.fn();
const target = document.createElement('div');
stop = onKeyDown('a', handler, { target });
keydown('a', {}, target);
expect(handler).toHaveBeenCalledTimes(1);
});
});
describe(onKeyUp, () => {
let stop: (() => void) | undefined;
afterEach(() => {
stop?.();
stop = undefined;
});
it('listens for keyup', () => {
const handler = vi.fn();
const target = document.createElement('div');
stop = onKeyUp('a', handler, { target });
keydown('a', {}, target);
expect(handler).not.toHaveBeenCalled();
target.dispatchEvent(new KeyboardEvent('keyup', { key: 'a' }));
expect(handler).toHaveBeenCalledTimes(1);
});
});
describe(onKeyPressed, () => {
let stop: (() => void) | undefined;
afterEach(() => {
stop?.();
stop = undefined;
});
it('listens for keypress', () => {
const handler = vi.fn();
const target = document.createElement('div');
stop = onKeyPressed('a', handler, { target });
keydown('a', {}, target);
expect(handler).not.toHaveBeenCalled();
target.dispatchEvent(new KeyboardEvent('keypress', { key: 'a' }));
expect(handler).toHaveBeenCalledTimes(1);
});
});
@@ -1,197 +0,0 @@
import type { VoidFunction } from '@robonen/stdlib';
import { isArray, isFunction, isString, noop } from '@robonen/stdlib';
import { toValue } from 'vue';
import type { MaybeRefOrGetter } from 'vue';
import { useEventListener } from '@/composables/browser/useEventListener';
import { defaultWindow } from '@/types';
export type KeyPredicate = (event: KeyboardEvent) => boolean;
export type KeyFilter = true | string | string[] | KeyPredicate;
export type KeyStrokeEventName = 'keydown' | 'keypress' | 'keyup';
export interface OnKeyStrokeOptions {
/**
* The keyboard event to listen for.
*
* @default 'keydown'
*/
eventName?: KeyStrokeEventName;
/**
* The element to attach the listener to.
*
* @default window
*/
target?: MaybeRefOrGetter<EventTarget | null | undefined>;
/**
* Register the listener as passive (cannot call `preventDefault`).
*
* @default false
*/
passive?: boolean;
/**
* Ignore auto-repeated keydown events while a key is held down.
*
* @default false
*/
dedupe?: MaybeRefOrGetter<boolean>;
}
/**
* Build a predicate from a key filter.
*
* - `function` → used as-is.
* - `string` → matches `event.key`.
* - `string[]` → matches any key in the list.
* - `true`/anything else → matches every event.
*/
function createKeyPredicate(keyFilter: KeyFilter): KeyPredicate {
if (isFunction(keyFilter))
return keyFilter;
if (isString(keyFilter))
return (event: KeyboardEvent) => event.key === keyFilter;
if (isArray(keyFilter))
return (event: KeyboardEvent) => keyFilter.includes(event.key);
return () => true;
}
/**
* @name onKeyStroke
* @category Browser
* @description Listen for keyboard strokes. Accepts a key, list of keys, or a predicate and
* fires the handler for matching events. Auto-cleans up on scope dispose.
*
* Overload 1: Explicit key filter
*/
export function onKeyStroke(key: KeyFilter, handler: (event: KeyboardEvent) => void, options?: OnKeyStrokeOptions): VoidFunction;
/**
* @name onKeyStroke
* @category Browser
* @description Listen for every keyboard stroke (no key filter).
*
* Overload 2: Omitted key filter (matches all keys)
*
* @param {(event: KeyboardEvent) => void} handler Callback invoked on a matching key event
* @param {OnKeyStrokeOptions} [options] Listener configuration
* @returns {VoidFunction} Stop handle that removes the listener
*
* @example
* onKeyStroke('ArrowDown', (e) => { e.preventDefault(); });
* onKeyStroke(['a', 'b', 'c'], (e) => console.log(e.key));
* onKeyStroke((e) => e.metaKey && e.key === 's', save, { eventName: 'keydown' });
*
* @since 0.0.15
*/
export function onKeyStroke(handler: (event: KeyboardEvent) => void, options?: OnKeyStrokeOptions): VoidFunction;
export function onKeyStroke(...args: any[]): VoidFunction {
let key: KeyFilter;
let handler: (event: KeyboardEvent) => void;
let options: OnKeyStrokeOptions = {};
if (args.length === 3) {
key = args[0];
handler = args[1];
options = args[2];
}
else if (args.length === 2) {
if (typeof args[1] === 'object') {
key = true;
handler = args[0];
options = args[1];
}
else {
key = args[0];
handler = args[1];
}
}
else {
key = true;
handler = args[0];
}
const {
target = defaultWindow,
eventName = 'keydown',
passive = false,
dedupe = false,
} = options;
if (!target)
return noop;
const predicate = createKeyPredicate(key);
const listener = (event: KeyboardEvent) => {
if (event.repeat && toValue(dedupe))
return;
if (predicate(event))
handler(event);
};
return useEventListener(target, eventName, listener, { passive });
}
/**
* @name onKeyDown
* @category Browser
* @description Listen for `keydown` strokes. Shorthand for `onKeyStroke` with `eventName: 'keydown'`.
*
* @param {KeyFilter} key Key, list of keys, or predicate to match
* @param {(event: KeyboardEvent) => void} handler Callback invoked on a matching key event
* @param {Omit<OnKeyStrokeOptions, 'eventName'>} [options] Listener configuration
* @returns {VoidFunction} Stop handle that removes the listener
*
* @example
* onKeyDown('Enter', submit);
*
* @since 0.0.15
*/
export function onKeyDown(key: KeyFilter, handler: (event: KeyboardEvent) => void, options: Omit<OnKeyStrokeOptions, 'eventName'> = {}): VoidFunction {
return onKeyStroke(key, handler, { ...options, eventName: 'keydown' });
}
/**
* @name onKeyUp
* @category Browser
* @description Listen for `keyup` strokes. Shorthand for `onKeyStroke` with `eventName: 'keyup'`.
*
* @param {KeyFilter} key Key, list of keys, or predicate to match
* @param {(event: KeyboardEvent) => void} handler Callback invoked on a matching key event
* @param {Omit<OnKeyStrokeOptions, 'eventName'>} [options] Listener configuration
* @returns {VoidFunction} Stop handle that removes the listener
*
* @example
* onKeyUp('Escape', close);
*
* @since 0.0.15
*/
export function onKeyUp(key: KeyFilter, handler: (event: KeyboardEvent) => void, options: Omit<OnKeyStrokeOptions, 'eventName'> = {}): VoidFunction {
return onKeyStroke(key, handler, { ...options, eventName: 'keyup' });
}
/**
* @name onKeyPressed
* @category Browser
* @description Listen for `keypress` strokes. Shorthand for `onKeyStroke` with `eventName: 'keypress'`.
*
* @param {KeyFilter} key Key, list of keys, or predicate to match
* @param {(event: KeyboardEvent) => void} handler Callback invoked on a matching key event
* @param {Omit<OnKeyStrokeOptions, 'eventName'>} [options] Listener configuration
* @returns {VoidFunction} Stop handle that removes the listener
*
* @example
* onKeyPressed('a', type);
*
* @since 0.0.15
*/
export function onKeyPressed(key: KeyFilter, handler: (event: KeyboardEvent) => void, options: Omit<OnKeyStrokeOptions, 'eventName'> = {}): VoidFunction {
return onKeyStroke(key, handler, { ...options, eventName: 'keypress' });
}
@@ -1,207 +0,0 @@
import { describe, expect, it } from 'vitest';
import { effectScope, isReadonly, nextTick } from 'vue';
import { useActiveElement } from '.';
describe(useActiveElement, () => {
it('tracks the focused element', async () => {
const input = document.createElement('input');
document.body.appendChild(input);
const scope = effectScope();
let active: ReturnType<typeof useActiveElement>;
scope.run(() => {
active = useActiveElement();
});
input.focus();
input.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
await nextTick();
expect(active!.value).toBe(input);
scope.stop();
input.remove();
});
it('returns a shallow ref (not readonly)', () => {
const scope = effectScope();
let active: ReturnType<typeof useActiveElement>;
scope.run(() => {
active = useActiveElement();
});
// shallowRef is writable internally; ensure we did not return a readonly wrapper
expect(isReadonly(active!)).toBeFalsy();
scope.stop();
});
it('reflects the active element on creation', () => {
const input = document.createElement('input');
document.body.appendChild(input);
input.focus();
const scope = effectScope();
let active: ReturnType<typeof useActiveElement>;
scope.run(() => {
active = useActiveElement();
});
// initial trigger should capture the already-focused element synchronously
expect(active!.value).toBe(input);
scope.stop();
input.remove();
});
it('traverses open shadow roots when deep', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const shadow = host.attachShadow({ mode: 'open' });
const inner = document.createElement('input');
shadow.appendChild(inner);
const scope = effectScope();
let active: ReturnType<typeof useActiveElement>;
scope.run(() => {
active = useActiveElement();
});
inner.focus();
document.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
await nextTick();
expect(active!.value).toBe(inner);
scope.stop();
host.remove();
});
it('does not descend into shadow roots when deep is false', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const shadow = host.attachShadow({ mode: 'open' });
const inner = document.createElement('input');
shadow.appendChild(inner);
const scope = effectScope();
let active: ReturnType<typeof useActiveElement>;
scope.run(() => {
active = useActiveElement({ deep: false });
});
inner.focus();
document.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
await nextTick();
// with deep:false we stay at the shadow host instead of piercing it
expect(active!.value).toBe(host);
scope.stop();
host.remove();
});
it('resets when focus leaves the window (blur with no relatedTarget)', async () => {
const input = document.createElement('input');
document.body.appendChild(input);
const scope = effectScope();
let active: ReturnType<typeof useActiveElement>;
scope.run(() => {
active = useActiveElement();
});
input.focus();
globalThis.dispatchEvent(new FocusEvent('focus'));
await nextTick();
expect(active!.value).toBe(input);
// simulate focus leaving the document entirely
input.blur();
globalThis.dispatchEvent(new FocusEvent('blur', { relatedTarget: null }));
await nextTick();
expect(active!.value).toBe(document.body);
scope.stop();
input.remove();
});
it('ignores window blur when focus moves to another element (relatedTarget set)', async () => {
const input = document.createElement('input');
document.body.appendChild(input);
const scope = effectScope();
let active: ReturnType<typeof useActiveElement>;
scope.run(() => {
active = useActiveElement();
});
input.focus();
await nextTick();
expect(active!.value).toBe(input);
// blur carrying a relatedTarget means focus stayed within the page -> ignore
const other = document.createElement('button');
globalThis.dispatchEvent(new FocusEvent('blur', { relatedTarget: other }));
await nextTick();
expect(active!.value).toBe(input);
scope.stop();
input.remove();
});
it('accepts a custom document via options', () => {
const fakeEl = document.createElement('textarea');
const fakeDocument = { activeElement: fakeEl } as unknown as Document;
const scope = effectScope();
let active: ReturnType<typeof useActiveElement>;
scope.run(() => {
active = useActiveElement({ document: fakeDocument });
});
expect(active!.value).toBe(fakeEl);
scope.stop();
});
it('does not throw and stays undefined when document has no active element', () => {
// emulate an environment (e.g. SSR / detached document) with no focus
const emptyDocument = { activeElement: null } as unknown as Document;
const scope = effectScope();
let active: ReturnType<typeof useActiveElement>;
scope.run(() => {
// pass a real window so listeners attach without error, but a doc with no focus
active = useActiveElement({ document: emptyDocument });
});
expect(active!.value).toBeNull();
scope.stop();
});
it('re-evaluates when the active element is removed (triggerOnRemoval)', async () => {
const input = document.createElement('input');
document.body.appendChild(input);
const scope = effectScope();
let active: ReturnType<typeof useActiveElement>;
scope.run(() => {
active = useActiveElement({ triggerOnRemoval: true });
});
input.focus();
await nextTick();
expect(active!.value).toBe(input);
input.remove();
// MutationObserver delivery is async; wait a microtask-ish tick
await new Promise(resolve => setTimeout(resolve, 0));
await nextTick();
expect(active!.value).toBe(document.body);
scope.stop();
});
});
@@ -1,111 +0,0 @@
import { shallowRef } from 'vue';
import type { ShallowRef } from 'vue';
import { defaultWindow } from '@/types';
import type { ConfigurableDocument, ConfigurableWindow } from '@/types';
import { useEventListener } from '@/composables/browser/useEventListener';
import { useMutationObserver } from '@/composables/browser/useMutationObserver';
export interface UseActiveElementOptions extends ConfigurableWindow, ConfigurableDocument {
/**
* Search for the active element inside open shadow roots
*
* @default true
*/
deep?: boolean;
/**
* Re-evaluate the active element when it is removed from the DOM.
* Uses a `MutationObserver` under the hood, so it is only enabled on demand.
*
* @default false
*/
triggerOnRemoval?: boolean;
}
export type UseActiveElementReturn<T extends HTMLElement = HTMLElement> = ShallowRef<T | null | undefined>;
/**
* @name useActiveElement
* @category Browser
* @description Reactive `document.activeElement`, traversing open shadow roots.
*
* @param {UseActiveElementOptions} [options={}] Options
* @returns {UseActiveElementReturn<T>} The currently focused element
*
* @example
* const active = useActiveElement();
*
* @example
* // keep tracking even if the focused node is detached from the DOM
* const active = useActiveElement({ triggerOnRemoval: true });
*
* @since 0.0.15
*/
export function useActiveElement<T extends HTMLElement>(
options: UseActiveElementOptions = {},
): UseActiveElementReturn<T> {
const {
window = defaultWindow,
deep = true,
triggerOnRemoval = false,
} = options;
const document = options.document ?? window?.document;
const getDeepActiveElement = (): Element | null | undefined => {
let element = document?.activeElement;
if (deep) {
while (element?.shadowRoot)
element = element.shadowRoot.activeElement;
}
return element;
};
const activeElement = shallowRef<T | null | undefined>();
const trigger = (): void => {
activeElement.value = getDeepActiveElement() as T | null | undefined;
};
if (window) {
const listenerOptions = { capture: true, passive: true } as const;
// `focus` (capture) catches focus moving onto any element, including those
// inside open shadow roots; `blur` with no `relatedTarget` resets the ref
// when focus leaves the document/window entirely.
useEventListener(
window,
'blur',
(event: FocusEvent) => {
if (event.relatedTarget !== null)
return;
trigger();
},
listenerOptions,
);
useEventListener(window, 'focus', trigger, listenerOptions);
}
if (triggerOnRemoval && document) {
useMutationObserver(
() => [document.body],
(mutations) => {
for (const mutation of mutations) {
for (const removed of mutation.removedNodes) {
if (removed === activeElement.value || removed.contains(activeElement.value as Node)) {
trigger();
return;
}
}
}
},
{ window, childList: true, subtree: true },
);
}
trigger();
return activeElement;
}
@@ -1,43 +0,0 @@
import { afterEach, describe, expect, it } from 'vitest';
import { useBodyScrollLock } from '.';
describe(useBodyScrollLock, () => {
afterEach(() => {
document.body.style.overflow = '';
document.body.style.paddingRight = '';
document.body.style.touchAction = '';
});
it('locks body overflow', () => {
const release = useBodyScrollLock();
expect(document.body.style.overflow).toBe('hidden');
release();
});
it('restores original overflow after release', () => {
document.body.style.overflow = 'auto';
const release = useBodyScrollLock();
expect(document.body.style.overflow).toBe('hidden');
release();
expect(document.body.style.overflow).toBe('auto');
});
it('reference-counts concurrent holders', () => {
const r1 = useBodyScrollLock();
const r2 = useBodyScrollLock();
expect(document.body.style.overflow).toBe('hidden');
r1();
expect(document.body.style.overflow).toBe('hidden');
r2();
expect(document.body.style.overflow).toBe('');
});
it('release is idempotent', () => {
const release = useBodyScrollLock();
release();
release();
expect(document.body.style.overflow).toBe('');
});
});
@@ -1,73 +0,0 @@
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
import { isClient } from '@robonen/platform/multi';
import type { VoidFunction } from '@robonen/stdlib';
import { noop } from '@robonen/stdlib';
interface LockState {
refs: number;
originalOverflow: string;
originalPaddingRight: string;
originalTouchAction: string;
}
let state: LockState | null = null;
function acquire(): VoidFunction {
if (!isClient) return noop;
if (!state) {
const { body, documentElement } = document;
const scrollbarWidth = globalThis.innerWidth - documentElement.clientWidth;
state = {
refs: 0,
originalOverflow: body.style.overflow,
originalPaddingRight: body.style.paddingRight,
originalTouchAction: body.style.touchAction,
};
body.style.overflow = 'hidden';
body.style.touchAction = 'none';
// Compensate scrollbar removal to prevent layout shift
if (scrollbarWidth > 0) {
const computedPr = Number.parseInt(globalThis.getComputedStyle(body).paddingRight, 10) || 0;
body.style.paddingRight = `${computedPr + scrollbarWidth}px`;
}
}
state.refs++;
let released = false;
const release = () => {
if (released || !state) return;
released = true;
state.refs--;
if (state.refs === 0) {
document.body.style.overflow = state.originalOverflow;
document.body.style.paddingRight = state.originalPaddingRight;
document.body.style.touchAction = state.originalTouchAction;
state = null;
}
};
tryOnScopeDispose(release);
return release;
}
/**
* @name useBodyScrollLock
* @category Browser
* @description Reference-counted body scroll lock. Safe to invoke from multiple
* concurrent modals — the lock releases only after all holders release. Preserves
* the original overflow/padding/touch-action values and compensates for scrollbar
* removal to prevent layout shift.
*
* @returns {VoidFunction} Release function. Idempotent — call once per acquire.
*
* @since 0.0.14
*/
export function useBodyScrollLock(): VoidFunction {
return acquire();
}
@@ -0,0 +1,343 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, ref } from 'vue';
import {
breakpointsAntDesign,
breakpointsBootstrapV5,
breakpointsTailwind,
breakpointsVuetifyV3,
useBreakpoints,
} from '.';
type Listener = (event: { matches: boolean }) => void;
interface StubMql {
readonly matches: boolean;
media: string;
addEventListener: (type: string, cb: Listener) => void;
removeEventListener: (type: string, cb: Listener) => void;
}
/**
* A `matchMedia` stub backed by a mutable viewport width. It parses
* `(min-width: Npx)` / `(max-width: Npx)` (optionally joined by `and`) so each
* query evaluates against the current `width`. Calling `setWidth` re-dispatches
* `change` to every live MediaQueryList, mimicking a real viewport resize.
*/
function stubViewport(initialWidth: number) {
let width = initialWidth;
const lists = new Set<{ media: string; matches: boolean; listeners: Set<Listener> }>();
function toPx(value: string): number {
const n = Number.parseFloat(value);
return /(?:em|rem)$/i.test(value) ? n * 16 : n;
}
function evaluate(media: string): boolean {
return media.split(' and ').every((part) => {
const min = part.match(/min-width:\s*(-?\d+(?:\.\d+)?(?:px|r?em)?)/);
const max = part.match(/max-width:\s*(-?\d+(?:\.\d+)?(?:px|r?em)?)/);
if (min) return width >= toPx(min[1]!);
if (max) return width <= toPx(max[1]!);
return false;
});
}
const matchMedia = vi.fn((media: string): StubMql => {
const entry = { media, matches: evaluate(media), listeners: new Set<Listener>() };
lists.add(entry);
return {
get matches() {
return entry.matches;
},
media,
addEventListener: (_: string, cb: Listener) => entry.listeners.add(cb),
removeEventListener: (_: string, cb: Listener) => entry.listeners.delete(cb),
};
});
vi.stubGlobal('matchMedia', matchMedia);
return {
matchMedia,
setWidth(next: number) {
width = next;
for (const entry of lists) {
const matches = evaluate(entry.media);
if (matches !== entry.matches) {
entry.matches = matches;
for (const cb of entry.listeners) cb({ matches });
}
}
},
};
}
describe(useBreakpoints, () => {
beforeEach(() => {
vi.stubGlobal('matchMedia', undefined);
});
afterEach(() => vi.unstubAllGlobals());
it('exposes a reactive shortcut ref per breakpoint (min-width strategy)', async () => {
stubViewport(800);
const scope = effectScope();
let bp: ReturnType<typeof useBreakpoints<'sm' | 'md' | 'lg'>>;
scope.run(() => {
bp = useBreakpoints({ sm: 640, md: 768, lg: 1024 });
});
await nextTick();
// 800px: >= sm (640), >= md (768), < lg (1024)
expect(bp!.sm.value).toBeTruthy();
expect(bp!.md.value).toBeTruthy();
expect(bp!.lg.value).toBeFalsy();
scope.stop();
});
it('uses max-width semantics for shortcuts under the max-width strategy', async () => {
stubViewport(800);
const scope = effectScope();
let bp: ReturnType<typeof useBreakpoints<'sm' | 'md' | 'lg'>>;
scope.run(() => {
bp = useBreakpoints({ sm: 640, md: 768, lg: 1024 }, { strategy: 'max-width' });
});
await nextTick();
// 800px: <= lg (1024) only; not <= sm/md
expect(bp!.sm.value).toBeFalsy();
expect(bp!.md.value).toBeFalsy();
expect(bp!.lg.value).toBeTruthy();
scope.stop();
});
it('reacts to viewport changes', async () => {
const vp = stubViewport(500);
const scope = effectScope();
let bp: ReturnType<typeof useBreakpoints<'sm' | 'lg'>>;
scope.run(() => {
bp = useBreakpoints({ sm: 640, lg: 1024 });
});
await nextTick();
expect(bp!.greaterOrEqual('sm').value).toBeFalsy();
vp.setWidth(700);
await nextTick();
expect(bp!.greaterOrEqual('sm').value).toBeTruthy();
expect(bp!.greaterOrEqual('lg').value).toBeFalsy();
vp.setWidth(1100);
await nextTick();
expect(bp!.greaterOrEqual('lg').value).toBeTruthy();
scope.stop();
});
it('greater/smaller apply the strict (exclusive) delta', async () => {
// Exactly at the md breakpoint (768).
const vp = stubViewport(768);
const scope = effectScope();
let bp: ReturnType<typeof useBreakpoints<'md'>>;
scope.run(() => {
bp = useBreakpoints({ md: 768 });
});
await nextTick();
// At exactly 768: greaterOrEqual true, greater false, smallerOrEqual true, smaller false.
expect(bp!.greaterOrEqual('md').value).toBeTruthy();
expect(bp!.greater('md').value).toBeFalsy();
expect(bp!.smallerOrEqual('md').value).toBeTruthy();
expect(bp!.smaller('md').value).toBeFalsy();
vp.setWidth(900);
await nextTick();
expect(bp!.greater('md').value).toBeTruthy();
expect(bp!.smaller('md').value).toBeFalsy();
scope.stop();
});
it('between is half-open [a, b)', async () => {
const vp = stubViewport(768);
const scope = effectScope();
let bp: ReturnType<typeof useBreakpoints<'sm' | 'md' | 'lg'>>;
scope.run(() => {
bp = useBreakpoints({ sm: 640, md: 768, lg: 1024 });
});
await nextTick();
// 768 is in [sm, lg) and in [md, lg); the upper bound (md) is exclusive in [sm, md).
expect(bp!.between('sm', 'lg').value).toBeTruthy();
expect(bp!.between('sm', 'md').value).toBeFalsy();
expect(bp!.between('md', 'lg').value).toBeTruthy();
scope.stop();
});
it('current() returns active breakpoints ordered small to large; active() picks per strategy', async () => {
stubViewport(800);
const scope = effectScope();
let bp: ReturnType<typeof useBreakpoints<'sm' | 'md' | 'lg'>>;
scope.run(() => {
bp = useBreakpoints({ lg: 1024, sm: 640, md: 768 });
});
await nextTick();
// 800px: sm and md active, sorted ascending.
expect(bp!.current().value).toEqual(['sm', 'md']);
// min-width strategy → largest active breakpoint.
expect(bp!.active().value).toBe('md');
scope.stop();
});
it('active() picks the smallest active breakpoint under max-width strategy', async () => {
stubViewport(800);
const scope = effectScope();
let bp: ReturnType<typeof useBreakpoints<'sm' | 'md' | 'lg'>>;
scope.run(() => {
bp = useBreakpoints({ sm: 640, md: 768, lg: 1024 }, { strategy: 'max-width' });
});
await nextTick();
expect(bp!.active().value).toBe('lg');
scope.stop();
});
it('synchronous is* helpers read the current match', async () => {
stubViewport(800);
const scope = effectScope();
let bp: ReturnType<typeof useBreakpoints<'sm' | 'md' | 'lg'>>;
scope.run(() => {
bp = useBreakpoints({ sm: 640, md: 768, lg: 1024 });
});
await nextTick();
expect(bp!.isGreaterOrEqual('md')).toBeTruthy();
expect(bp!.isGreater('md')).toBeTruthy();
expect(bp!.isGreaterOrEqual('lg')).toBeFalsy();
expect(bp!.isSmallerOrEqual('lg')).toBeTruthy();
expect(bp!.isSmaller('sm')).toBeFalsy();
expect(bp!.isInBetween('sm', 'lg')).toBeTruthy();
expect(bp!.isInBetween('sm', 'md')).toBeFalsy();
scope.stop();
});
it('supports reactive breakpoint values', async () => {
const vp = stubViewport(700);
const threshold = ref(640);
const scope = effectScope();
let bp: ReturnType<typeof useBreakpoints<'point'>>;
scope.run(() => {
bp = useBreakpoints({ point: threshold });
});
await nextTick();
expect(bp!.greaterOrEqual('point').value).toBeTruthy();
threshold.value = 900;
await nextTick();
expect(bp!.greaterOrEqual('point').value).toBeFalsy();
vp.setWidth(1000);
await nextTick();
expect(bp!.greaterOrEqual('point').value).toBeTruthy();
scope.stop();
});
it('handles string unit breakpoint values', async () => {
stubViewport(800); // 800px, 48em = 768px
const scope = effectScope();
let bp: ReturnType<typeof useBreakpoints<'md'>>;
scope.run(() => {
bp = useBreakpoints({ md: '48em' });
});
await nextTick();
expect(bp!.greaterOrEqual('md').value).toBeTruthy();
expect(bp!.isGreaterOrEqual('md')).toBeTruthy();
scope.stop();
});
describe('SSR / unsupported path', () => {
it('resolves width queries from ssrWidth when matchMedia is unavailable', () => {
// matchMedia stays undefined (beforeEach), window has no matchMedia path used by match().
const scope = effectScope();
let bp: ReturnType<typeof useBreakpoints<'sm' | 'lg'>>;
scope.run(() => {
bp = useBreakpoints({ sm: 640, lg: 1024 }, { ssrWidth: 800 });
});
// Synchronous helpers resolve against ssrWidth.
expect(bp!.isGreaterOrEqual('sm')).toBeTruthy();
expect(bp!.isGreaterOrEqual('lg')).toBeFalsy();
expect(bp!.isSmallerOrEqual('lg')).toBeTruthy();
scope.stop();
});
it('returns false for snapshot helpers with no window and no ssrWidth', () => {
const scope = effectScope();
let bp: ReturnType<typeof useBreakpoints<'sm'>>;
scope.run(() => {
bp = useBreakpoints({ sm: 640 }, { window: undefined });
});
expect(bp!.isGreaterOrEqual('sm')).toBeFalsy();
expect(bp!.isSmallerOrEqual('sm')).toBeFalsy();
scope.stop();
});
it('does not throw when constructed without a window (SSR)', () => {
const scope = effectScope();
expect(() => {
scope.run(() => {
const bp = useBreakpoints(breakpointsTailwind, { window: undefined });
// Accessing reactive refs should be safe and default to false.
expect(bp.lg.value).toBeFalsy();
});
}).not.toThrow();
scope.stop();
});
});
describe('presets', () => {
it('exports the expected preset values', () => {
expect(breakpointsTailwind).toMatchObject({ sm: 640, md: 768, lg: 1024, xl: 1280, '2xl': 1536 });
expect(breakpointsBootstrapV5).toMatchObject({ xs: 0, sm: 576, md: 768, lg: 992, xl: 1200, xxl: 1400 });
expect(breakpointsAntDesign).toMatchObject({ xs: 480, sm: 576, md: 768, lg: 992, xl: 1200, xxl: 1600 });
expect(breakpointsVuetifyV3).toMatchObject({ xs: 0, sm: 600, md: 960, lg: 1280, xl: 1920, xxl: 2560 });
});
it('works with a preset', async () => {
stubViewport(1300);
const scope = effectScope();
let bp: ReturnType<typeof useBreakpoints<keyof typeof breakpointsTailwind & string>>;
scope.run(() => {
bp = useBreakpoints(breakpointsTailwind);
});
await nextTick();
expect(bp!.greaterOrEqual('xl').value).toBeTruthy();
expect(bp!.greaterOrEqual('2xl').value).toBeFalsy();
expect(bp!.active().value).toBe('xl');
scope.stop();
});
});
});
@@ -0,0 +1,266 @@
import { computed, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
import { isFunction, isNumber } from '@robonen/stdlib';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { useMediaQuery } from '@/composables/browser/useMediaQuery';
import type { UseMediaQueryOptions } from '@/composables/browser/useMediaQuery';
/**
* A breakpoints map: name → viewport width. Numbers are treated as pixels;
* strings keep their unit (`"48em"`, `"30rem"`, `"1024px"`). Values may be
* reactive (refs or getters).
*/
export type Breakpoints<K extends string = string>
= Record<K, MaybeRefOrGetter<number | string>>;
/**
* Which edge a generated shortcut property (e.g. `breakpoints.lg`) reacts to.
*
* - `'min-width'` (mobile-first) — `lg` is `true` when the viewport is at least
* the `lg` width.
* - `'max-width'` (desktop-first) — `lg` is `true` when the viewport is at most
* the `lg` width.
*/
export type UseBreakpointsStrategy = 'min-width' | 'max-width';
export interface UseBreakpointsOptions extends ConfigurableWindow, Pick<UseMediaQueryOptions, 'ssrWidth'> {
/**
* The query strategy used by the generated shortcut properties.
*
* @default 'min-width'
*/
strategy?: UseBreakpointsStrategy;
}
export type UseBreakpointsReturn<K extends string = string>
= Record<K, ComputedRef<boolean>> & {
/** Reactive: viewport width is greater than or equal to breakpoint `k` (`min-width`). */
greaterOrEqual: (k: MaybeRefOrGetter<K>) => ComputedRef<boolean>;
/** Reactive: viewport width is smaller than or equal to breakpoint `k` (`max-width`). */
smallerOrEqual: (k: MaybeRefOrGetter<K>) => ComputedRef<boolean>;
/** Reactive: viewport width is strictly greater than breakpoint `k`. */
greater: (k: MaybeRefOrGetter<K>) => ComputedRef<boolean>;
/** Reactive: viewport width is strictly smaller than breakpoint `k`. */
smaller: (k: MaybeRefOrGetter<K>) => ComputedRef<boolean>;
/** Reactive: viewport width is within `[a, b)`. */
between: (a: MaybeRefOrGetter<K>, b: MaybeRefOrGetter<K>) => ComputedRef<boolean>;
/** Snapshot: viewport width is strictly greater than breakpoint `k`. */
isGreater: (k: MaybeRefOrGetter<K>) => boolean;
/** Snapshot: viewport width is greater than or equal to breakpoint `k`. */
isGreaterOrEqual: (k: MaybeRefOrGetter<K>) => boolean;
/** Snapshot: viewport width is strictly smaller than breakpoint `k`. */
isSmaller: (k: MaybeRefOrGetter<K>) => boolean;
/** Snapshot: viewport width is smaller than or equal to breakpoint `k`. */
isSmallerOrEqual: (k: MaybeRefOrGetter<K>) => boolean;
/** Snapshot: viewport width is within `[a, b)`. */
isInBetween: (a: MaybeRefOrGetter<K>, b: MaybeRefOrGetter<K>) => boolean;
/** Reactive: all currently active breakpoints, ordered small → large. */
current: () => ComputedRef<K[]>;
/** Reactive: the single active breakpoint per `strategy` (largest for `min-width`, smallest for `max-width`), or `''` when none. */
active: () => ComputedRef<K | ''>;
};
/**
* Parse a CSS length token (`"1024px"`, `"48em"`, `"30rem"`, `"50%"`) into a
* pixel number. `em`/`rem` use the conventional 16px root size.
*/
function pxValue(value: string): number {
const number = Number.parseFloat(value);
if (Number.isNaN(number))
return Number.NaN;
if (/(?:em|rem)\s*$/i.test(value))
return number * 16;
return number;
}
/**
* Add `delta` to the numeric portion of a CSS length, preserving its unit.
* Used to build the strict (`> / <`) variants from inclusive media queries via
* a small ±0.1 nudge.
*/
function increaseWithUnit(target: number | string, delta: number): number | string {
if (isNumber(target))
return target + delta;
const value = target.match(/^-?\d+(?:\.\d+)?/)?.[0] ?? '';
const unit = target.slice(value.length);
const result = Number.parseFloat(value) + delta;
if (Number.isNaN(result))
return target;
return result + unit;
}
/**
* @name useBreakpoints
* @category Browser
* @description Reactive viewport breakpoints derived from a breakpoints map.
* SSR-safe (resolves width queries from `ssrWidth` before `matchMedia` exists),
* reactive to breakpoint values, and built on a single `useMediaQuery` per
* comparison. Comes with presets: `breakpointsTailwind`, `breakpointsBootstrapV5`,
* `breakpointsAntDesign`, `breakpointsVuetifyV3`.
*
* @param {Breakpoints<K>} breakpoints The breakpoints map (`name → width`)
* @param {UseBreakpointsOptions} [options={}] Options (`strategy`, custom `window`, `ssrWidth`)
* @returns {UseBreakpointsReturn<K>} Shortcut refs per breakpoint plus comparison helpers
*
* @example
* const bp = useBreakpoints(breakpointsTailwind);
* const isDesktop = bp.greaterOrEqual('lg');
* const isMobile = bp.smaller('md');
* bp.lg; // ComputedRef<boolean> — true when viewport >= 1024px
*
* @example
* const bp = useBreakpoints({ mobile: 0, tablet: 640, desktop: 1024 });
* const active = bp.active(); // ComputedRef<'mobile' | 'tablet' | 'desktop' | ''>
*
* @since 0.0.15
*/
export function useBreakpoints<K extends string>(
breakpoints: Breakpoints<K>,
options: UseBreakpointsOptions = {},
): UseBreakpointsReturn<K> {
const { window = defaultWindow, strategy = 'min-width', ssrWidth } = options;
const mediaOptions: UseMediaQueryOptions = { window, ssrWidth };
const ssrSupport = isNumber(ssrWidth);
function getValue(k: MaybeRefOrGetter<K>, delta?: number): string {
let v = toValue(breakpoints[toValue(k)]);
if (delta !== undefined)
v = increaseWithUnit(v, delta);
return isNumber(v) ? `${v}px` : v;
}
// Synchronous (non-reactive) match for the `is*` snapshot helpers.
function match(edge: 'min' | 'max', size: string): boolean {
const supported = window && isFunction(window.matchMedia);
if (!supported)
return ssrSupport
? (edge === 'min' ? ssrWidth >= pxValue(size) : ssrWidth <= pxValue(size))
: false;
return window.matchMedia(`(${edge}-width: ${size})`).matches;
}
const greaterOrEqual = (k: MaybeRefOrGetter<K>): ComputedRef<boolean> =>
useMediaQuery(() => `(min-width: ${getValue(k)})`, mediaOptions);
const smallerOrEqual = (k: MaybeRefOrGetter<K>): ComputedRef<boolean> =>
useMediaQuery(() => `(max-width: ${getValue(k)})`, mediaOptions);
const greater = (k: MaybeRefOrGetter<K>): ComputedRef<boolean> =>
useMediaQuery(() => `(min-width: ${getValue(k, 0.1)})`, mediaOptions);
const smaller = (k: MaybeRefOrGetter<K>): ComputedRef<boolean> =>
useMediaQuery(() => `(max-width: ${getValue(k, -0.1)})`, mediaOptions);
const between = (a: MaybeRefOrGetter<K>, b: MaybeRefOrGetter<K>): ComputedRef<boolean> =>
useMediaQuery(() => `(min-width: ${getValue(a)}) and (max-width: ${getValue(b, -0.1)})`, mediaOptions);
const keys = Object.keys(breakpoints) as K[];
// Generated shortcut properties (`bp.lg`). Lazily created getters so we only
// spin up a `useMediaQuery` watcher for the breakpoints actually accessed.
const shortcuts = keys.reduce((acc, k) => {
Object.defineProperty(acc, k, {
get: () => strategy === 'min-width' ? greaterOrEqual(k) : smallerOrEqual(k),
enumerable: true,
configurable: true,
});
return acc;
}, {} as Record<K, ComputedRef<boolean>>);
function current(): ComputedRef<K[]> {
const points = keys
.map(k => [k, shortcuts[k], pxValue(getValue(k))] as const)
.sort((a, b) => a[2] - b[2]);
return computed(() => points.filter(([, matches]) => matches.value).map(([k]) => k));
}
return Object.assign(shortcuts, {
greaterOrEqual,
smallerOrEqual,
greater,
smaller,
between,
isGreater: (k: MaybeRefOrGetter<K>): boolean => match('min', getValue(k, 0.1)),
isGreaterOrEqual: (k: MaybeRefOrGetter<K>): boolean => match('min', getValue(k)),
isSmaller: (k: MaybeRefOrGetter<K>): boolean => match('max', getValue(k, -0.1)),
isSmallerOrEqual: (k: MaybeRefOrGetter<K>): boolean => match('max', getValue(k)),
isInBetween: (a: MaybeRefOrGetter<K>, b: MaybeRefOrGetter<K>): boolean =>
match('min', getValue(a)) && match('max', getValue(b, -0.1)),
current,
active(): ComputedRef<K | ''> {
const bps = current();
return computed(() => bps.value.length === 0
? ''
: bps.value.at(strategy === 'min-width' ? -1 : 0)!);
},
});
}
/**
* Tailwind CSS default breakpoints.
*
* @see https://tailwindcss.com/docs/responsive-design
*/
export const breakpointsTailwind = {
sm: 640,
md: 768,
lg: 1024,
xl: 1280,
'2xl': 1536,
};
/**
* Bootstrap v5 default breakpoints.
*
* @see https://getbootstrap.com/docs/5.0/layout/breakpoints/
*/
export const breakpointsBootstrapV5 = {
xs: 0,
sm: 576,
md: 768,
lg: 992,
xl: 1200,
xxl: 1400,
};
/**
* Ant Design default breakpoints.
*
* @see https://ant.design/components/grid#col
*/
export const breakpointsAntDesign = {
xs: 480,
sm: 576,
md: 768,
lg: 992,
xl: 1200,
xxl: 1600,
};
/**
* Vuetify v3 default breakpoints.
*
* @see https://vuetifyjs.com/en/features/display-and-platform/
*/
export const breakpointsVuetifyV3 = {
xs: 0,
sm: 600,
md: 960,
lg: 1280,
xl: 1920,
xxl: 2560,
};
@@ -1,77 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import { mount } from '@vue/test-utils';
import { useClickOutside } from '.';
function mountWithOutside(handler: (e: Event) => void) {
const Comp = defineComponent({
setup() {
const target = ref<HTMLElement | null>(null);
return () => h('div', {
ref: (el: any) => { target.value = el; },
'data-testid': 'target',
}, [
h('button', { 'data-testid': 'inside' }, 'inside'),
]);
},
mounted() {
useClickOutside(() => this.$el, handler);
},
});
return mount(Comp, { attachTo: document.body });
}
describe(useClickOutside, () => {
it('invokes handler on outside pointerdown', async () => {
const handler = vi.fn();
const w = mountWithOutside(handler);
const outside = document.createElement('button');
document.body.appendChild(outside);
await nextTick();
outside.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true }));
expect(handler).toHaveBeenCalledTimes(1);
outside.remove();
w.unmount();
});
it('does not invoke handler on inside pointerdown', async () => {
const handler = vi.fn();
const w = mountWithOutside(handler);
await nextTick();
const inside = w.find('[data-testid=inside]').element as HTMLElement;
inside.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true }));
expect(handler).not.toHaveBeenCalled();
w.unmount();
});
it('respects the ignore list', async () => {
const handler = vi.fn();
const ignored = document.createElement('div');
document.body.appendChild(ignored);
const Comp = defineComponent({
setup() {
return () => h('div', { 'data-testid': 'target' }, 'target');
},
mounted() {
useClickOutside(() => this.$el, handler, { ignore: [ignored] });
},
});
const w = mount(Comp, { attachTo: document.body });
await nextTick();
ignored.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true }));
expect(handler).not.toHaveBeenCalled();
ignored.remove();
w.unmount();
});
});
@@ -1,68 +0,0 @@
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
import { unrefElement } from '@/composables/component/unrefElement';
import { useEventListener } from '@/composables/browser/useEventListener';
import { defaultWindow } from '@/types';
import type { MaybeRefOrGetter } from 'vue';
import { toValue } from 'vue';
import type { VoidFunction } from '@robonen/stdlib';
import { noop } from '@robonen/stdlib';
export interface UseClickOutsideOptions {
/**
* Elements that are inside `target` semantically but physically rendered
* elsewhere (e.g. portaled menus). Events originating in these nodes
* are treated as *inside* clicks.
*/
ignore?: MaybeRefOrGetter<Array<MaybeComputedElementRef | undefined>>;
/**
* Detect outside pointer-down instead of click. Useful for dismissable layers
* that want to react as soon as the user starts interacting outside.
* @default 'pointerdown'
*/
event?: 'pointerdown' | 'mousedown' | 'click';
}
/**
* @name useClickOutside
* @category Browser
* @description Invokes `handler` when a pointer event occurs outside `target`.
* SSR-safe: no-op on the server. Handles portaled/ignored subtrees and
* guards against synthetic "outside" clicks on removed nodes.
*
* @param {MaybeComputedElementRef} target Element to watch. Events inside it are ignored.
* @param {(event: PointerEvent | MouseEvent) => void} handler Callback invoked with the outside event
* @param {UseClickOutsideOptions} [options] Options
* @returns {VoidFunction} Stop handle to remove the listeners
*
* @since 0.0.14
*/
export function useClickOutside(
target: MaybeComputedElementRef,
handler: (event: PointerEvent | MouseEvent) => void,
options: UseClickOutsideOptions = {},
): VoidFunction {
if (!defaultWindow) return noop;
const { event = 'pointerdown', ignore } = options;
const listener = (e: Event) => {
const el = unrefElement(target) as HTMLElement | undefined;
const pe = e as PointerEvent;
const path = (e.composedPath?.() ?? []) as Node[];
const eventTarget = (path[0] ?? e.target) as Node | null;
if (!el || !eventTarget) return;
if (el === eventTarget || el.contains(eventTarget)) return;
const ignoreList = toValue(ignore) ?? [];
for (const ref of ignoreList) {
const node = unrefElement(ref) as HTMLElement | undefined;
if (node && (node === eventTarget || node.contains(eventTarget))) return;
}
handler(pe);
};
return useEventListener(defaultWindow, event, listener, { passive: true, capture: true });
}
@@ -3,9 +3,9 @@ import type { MaybeRefOrGetter, Ref } from 'vue';
import { isString } from '@robonen/stdlib';
import { defaultNavigator } from '@/types';
import type { ConfigurableNavigator } from '@/types';
import { useSupported } from '@/composables/browser/useSupported';
import { useSupported } from '@/composables/utilities/useSupported';
import { useEventListener } from '@/composables/browser/useEventListener';
import { useTimeoutFn } from '@/composables/utilities/useTimeoutFn';
import { useTimeoutFn } from '@/composables/animation/useTimeoutFn';
/**
* A value to copy: either a string or an (optionally async) getter that resolves to one.
@@ -0,0 +1,228 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick } from 'vue';
import type { UseClipboardItemsReturn } from '.';
import { useClipboardItems } from '.';
function makeItems(label: string): ClipboardItems {
// jsdom may lack ClipboardItem; a tagged plain object is enough for assertions.
return [{ types: [label] } as unknown as ClipboardItem];
}
function stubClipboard(readItems: ClipboardItems = makeItems('read')) {
const write = vi.fn(async () => {});
const read = vi.fn(async () => readItems);
const navigator = {
clipboard: { write, read },
} as unknown as Navigator;
return { navigator, write, read };
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe(useClipboardItems, () => {
it('reports support when the clipboard API exists', () => {
const { navigator } = stubClipboard();
const scope = effectScope();
let clip: UseClipboardItemsReturn<false>;
scope.run(() => {
clip = useClipboardItems({ navigator });
});
expect(clip!.isSupported.value).toBeTruthy();
scope.stop();
});
it('is not supported without the clipboard API', () => {
const navigator = {} as unknown as Navigator;
const scope = effectScope();
let clip: UseClipboardItemsReturn<false>;
scope.run(() => {
clip = useClipboardItems({ navigator });
});
expect(clip!.isSupported.value).toBeFalsy();
scope.stop();
});
it('is not supported when navigator is undefined (SSR)', () => {
const scope = effectScope();
let clip: UseClipboardItemsReturn<false>;
scope.run(() => {
clip = useClipboardItems({ navigator: undefined });
});
expect(clip!.isSupported.value).toBeFalsy();
scope.stop();
});
it('copies items and sets copied flag', async () => {
const { navigator, write } = stubClipboard();
const items = makeItems('copy');
const scope = effectScope();
let clip: UseClipboardItemsReturn<false>;
scope.run(() => {
clip = useClipboardItems({ navigator });
});
await clip!.copy(items);
expect(write).toHaveBeenCalledWith(items);
expect(clip!.content.value).toBe(items);
expect(clip!.copied.value).toBeTruthy();
scope.stop();
});
it('copies the configured source when called without args', async () => {
const { navigator, write } = stubClipboard();
const items = makeItems('source');
const scope = effectScope();
let clip: any;
scope.run(() => {
clip = useClipboardItems({ navigator, source: items });
});
await clip.copy();
expect(write).toHaveBeenCalledWith(items);
scope.stop();
});
it('copies a value resolved from an async getter', async () => {
const { navigator, write } = stubClipboard();
const items = makeItems('lazy');
const scope = effectScope();
let clip: UseClipboardItemsReturn<false>;
scope.run(() => {
clip = useClipboardItems({ navigator });
});
await clip!.copy(async () => items);
expect(write).toHaveBeenCalledWith(items);
expect(clip!.content.value).toBe(items);
scope.stop();
});
it('skips when an async getter resolves to undefined', async () => {
const { navigator, write } = stubClipboard();
const scope = effectScope();
let clip: UseClipboardItemsReturn<false>;
scope.run(() => {
clip = useClipboardItems({ navigator });
});
await clip!.copy(async () => undefined);
expect(write).not.toHaveBeenCalled();
expect(clip!.copied.value).toBeFalsy();
scope.stop();
});
it('exposes copyPending around an in-flight async copy', async () => {
const { navigator } = stubClipboard();
const items = makeItems('done');
const scope = effectScope();
let clip: UseClipboardItemsReturn<false>;
scope.run(() => {
clip = useClipboardItems({ navigator });
});
let release: (v: ClipboardItems) => void = () => {};
const promise = clip!.copy(() => new Promise<ClipboardItems>((resolve) => {
release = resolve;
}));
expect(clip!.copyPending.value).toBeTruthy();
release(items);
await promise;
expect(clip!.copyPending.value).toBeFalsy();
expect(clip!.content.value).toBe(items);
scope.stop();
});
it('ignores a stale async copy superseded by a newer one', async () => {
const { navigator, write } = stubClipboard();
const fastItems = makeItems('fast');
const slowItems = makeItems('slow');
const scope = effectScope();
let clip: UseClipboardItemsReturn<false>;
scope.run(() => {
clip = useClipboardItems({ navigator });
});
let releaseSlow: (v: ClipboardItems) => void = () => {};
const slow = clip!.copy(() => new Promise<ClipboardItems>((resolve) => {
releaseSlow = resolve;
}));
const fast = clip!.copy(async () => fastItems);
await fast;
releaseSlow(slowItems);
await slow;
expect(clip!.content.value).toBe(fastItems);
expect(write).toHaveBeenCalledTimes(1);
expect(write).toHaveBeenCalledWith(fastItems);
scope.stop();
});
it('does nothing when unsupported', async () => {
const navigator = {} as unknown as Navigator;
const scope = effectScope();
let clip: UseClipboardItemsReturn<false>;
scope.run(() => {
clip = useClipboardItems({ navigator });
});
await clip!.copy(makeItems('x'));
expect(clip!.copied.value).toBeFalsy();
scope.stop();
});
it('reads the clipboard via read()', async () => {
const readItems = makeItems('from-clipboard');
const { navigator, read } = stubClipboard(readItems);
const scope = effectScope();
let clip: UseClipboardItemsReturn<false>;
scope.run(() => {
clip = useClipboardItems({ navigator });
});
await clip!.read();
expect(read).toHaveBeenCalled();
expect(clip!.content.value).toBe(readItems);
scope.stop();
});
it('syncs content on copy/cut events when read is enabled', async () => {
const readItems = makeItems('synced');
const { navigator, read } = stubClipboard(readItems);
const scope = effectScope();
let clip: UseClipboardItemsReturn<false>;
scope.run(() => {
clip = useClipboardItems({ navigator, read: true });
});
globalThis.dispatchEvent(new Event('copy'));
await nextTick();
await Promise.resolve();
expect(read).toHaveBeenCalled();
expect(clip!.content.value).toBe(readItems);
scope.stop();
});
it('routes a rejected write to onError instead of throwing', async () => {
const error = new Error('denied');
const write = vi.fn(async () => {
throw error;
});
const navigator = {
clipboard: { write, read: vi.fn() },
} as unknown as Navigator;
const onError = vi.fn();
const scope = effectScope();
let clip: UseClipboardItemsReturn<false>;
scope.run(() => {
clip = useClipboardItems({ navigator, onError });
});
await clip!.copy(makeItems('boom'));
expect(onError).toHaveBeenCalledWith(error);
expect(clip!.copied.value).toBeFalsy();
expect(clip!.copyPending.value).toBeFalsy();
scope.stop();
});
});
@@ -0,0 +1,185 @@
import { shallowReadonly, shallowRef, toValue } from 'vue';
import type { MaybeRefOrGetter, Ref } from 'vue';
import { isFunction, noop } from '@robonen/stdlib';
import { defaultNavigator } from '@/types';
import type { ConfigurableNavigator } from '@/types';
import { useSupported } from '@/composables/utilities/useSupported';
import { useEventListener } from '@/composables/browser/useEventListener';
import { useTimeoutFn } from '@/composables/animation/useTimeoutFn';
/**
* A value to copy: either concrete `ClipboardItems` or an (optionally async)
* getter that resolves to them.
*/
export type ClipboardItemsValue
= | ClipboardItems
| (() => Promise<ClipboardItems | undefined> | ClipboardItems | undefined);
export interface UseClipboardItemsOptions<Source> extends ConfigurableNavigator {
/**
* Sync `content` with the system clipboard by listening to copy/cut events
*
* @default false
*/
read?: boolean;
/**
* Default source value to copy when `copy()` is called without an argument
*/
source?: Source;
/**
* Milliseconds the `copied` flag stays `true` after a copy
*
* @default 1500
*/
copiedDuring?: number;
/**
* Called when a read/write rejects, instead of throwing
*
* @default noop
*/
onError?: (error: unknown) => void;
}
export interface UseClipboardItemsReturn<Optional extends boolean> {
/**
* Whether the async Clipboard API (with `ClipboardItem`) is available
*/
isSupported: Readonly<Ref<boolean>>;
/**
* The current clipboard items (kept in sync when `read` is enabled)
*/
content: Readonly<Ref<ClipboardItems>>;
/**
* `true` for `copiedDuring` ms after a successful copy
*/
copied: Readonly<Ref<boolean>>;
/**
* `true` while an async `copy()` is in flight
*/
copyPending: Readonly<Ref<boolean>>;
/**
* Copy clipboard items to the system clipboard
*/
copy: Optional extends true
? (content?: ClipboardItemsValue) => Promise<void>
: (content: ClipboardItemsValue) => Promise<void>;
/**
* Manually read the system clipboard into `content`
*/
read: () => Promise<void>;
}
/**
* @name useClipboardItems
* @category Browser
* @description Reactive async Clipboard API with rich `ClipboardItem` support
* (read/write images, HTML, and arbitrary MIME types — not just text).
* SSR-safe; uses passive `copy`/`cut` listeners and guards stale async writes.
*
* @param {UseClipboardItemsOptions} [options={}] Options
* @returns {UseClipboardItemsReturn} `isSupported`, `content`, `copied`, `copyPending`, `copy`, and `read`
*
* @example
* const { content, copy, copied, isSupported } = useClipboardItems();
* copy([new ClipboardItem({ 'text/plain': new Blob(['hello'], { type: 'text/plain' }) })]);
*
* @example
* // Copy a lazily/asynchronously resolved value, kept in sync with the system clipboard
* const { content } = useClipboardItems({ read: true });
* copy(async () => buildClipboardItems());
*
* @since 0.0.15
*/
export function useClipboardItems(options?: UseClipboardItemsOptions<undefined>): UseClipboardItemsReturn<false>;
export function useClipboardItems(options: UseClipboardItemsOptions<MaybeRefOrGetter<ClipboardItems>>): UseClipboardItemsReturn<true>;
export function useClipboardItems(
options: UseClipboardItemsOptions<MaybeRefOrGetter<ClipboardItems> | undefined> = {},
): UseClipboardItemsReturn<boolean> {
const {
navigator = defaultNavigator,
read = false,
source,
copiedDuring = 1500,
onError = noop,
} = options;
const isSupported = useSupported(() => navigator && 'clipboard' in navigator);
const content = shallowRef<ClipboardItems>([]);
const copied = shallowRef(false);
const copyPending = shallowRef(false);
// Guards against a slow async copy clobbering the result of a newer one
let lastResolveId = 0;
const timeout = useTimeoutFn(() => {
copied.value = false;
}, copiedDuring, { immediate: false });
async function updateContent(): Promise<void> {
if (!isSupported.value)
return;
try {
content.value = await navigator!.clipboard.read();
}
catch (error) {
onError(error);
}
}
if (isSupported.value && read)
useEventListener(['copy', 'cut'], updateContent, { passive: true });
async function copy(value: ClipboardItemsValue | undefined = toValue(source)): Promise<void> {
if (!isSupported.value || value === null || value === undefined)
return;
copyPending.value = true;
try {
let resolved: ClipboardItems | undefined;
if (isFunction(value)) {
const currentId = ++lastResolveId;
resolved = await value();
// Drop a stale async resolution superseded by a newer copy
if (resolved === null || resolved === undefined || currentId !== lastResolveId)
return;
}
else {
resolved = value;
}
await navigator!.clipboard.write(resolved);
content.value = resolved;
copied.value = true;
timeout.start();
}
catch (error) {
onError(error);
}
finally {
copyPending.value = false;
}
}
return {
isSupported,
content: shallowReadonly(content),
copied: shallowReadonly(copied),
copyPending: shallowReadonly(copyPending),
copy,
read: updateContent,
};
}
@@ -3,7 +3,7 @@ import { noop } from '@robonen/stdlib';
import type { Ref } from 'vue';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { useSupported } from '@/composables/browser/useSupported';
import { useSupported } from '@/composables/utilities/useSupported';
import { useEventListener } from '@/composables/browser/useEventListener';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
@@ -186,12 +186,12 @@ export function useColorMode<T extends string = BasicColorMode>(
let attributeToChange: { key: string; value: string } | null = null;
if (attr === 'class') {
const next = value.split(/\s/g);
const next = new Set(value.split(/\s/g));
// Toggle only the classes this composable owns (derived from `modes`),
// so unrelated classes on the element are left untouched.
for (const owned of Object.values<string>(modes).flatMap(mode => (mode || '').split(/\s/g)).filter(Boolean)) {
if (next.includes(owned))
if (next.has(owned))
classesToAdd.add(owned);
else
classesToRemove.add(owned);
@@ -0,0 +1,234 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, ref } from 'vue';
import { useCssVar } from '.';
let mutationInstances: Array<{ cb: MutationCallback; observe: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn> }> = [];
class StubMutationObserver {
observe = vi.fn();
disconnect = vi.fn();
takeRecords = vi.fn(() => []);
cb: MutationCallback;
constructor(cb: MutationCallback) {
this.cb = cb;
mutationInstances.push(this);
}
}
describe(useCssVar, () => {
afterEach(() => {
vi.unstubAllGlobals();
mutationInstances = [];
});
it('reads the existing custom property from the target', () => {
const el = document.createElement('div');
el.style.setProperty('--color', 'red');
document.body.appendChild(el);
const scope = effectScope();
let color: ReturnType<typeof useCssVar>;
scope.run(() => {
color = useCssVar('--color', el);
});
expect(color!.value).toBe('red');
scope.stop();
});
it('writes the property to the element when set', async () => {
const el = document.createElement('div');
document.body.appendChild(el);
const scope = effectScope();
let color: ReturnType<typeof useCssVar>;
scope.run(() => {
color = useCssVar('--color', el);
});
color!.value = 'blue';
await nextTick();
expect(el.style.getPropertyValue('--color')).toBe('blue');
expect(color!.value).toBe('blue');
scope.stop();
});
it('removes the property when set to null', () => {
const el = document.createElement('div');
el.style.setProperty('--color', 'green');
document.body.appendChild(el);
const scope = effectScope();
let color: ReturnType<typeof useCssVar>;
scope.run(() => {
color = useCssVar('--color', el);
});
color!.value = null;
expect(el.style.getPropertyValue('--color')).toBe('');
expect(color!.value).toBeNull();
scope.stop();
});
it('falls back to initialValue when the property is unset', () => {
const el = document.createElement('div');
document.body.appendChild(el);
const scope = effectScope();
let color: ReturnType<typeof useCssVar>;
scope.run(() => {
color = useCssVar('--missing', el, { initialValue: 'gray' });
});
expect(color!.value).toBe('gray');
scope.stop();
});
it('defaults the target to document.documentElement', () => {
document.documentElement.style.setProperty('--root-var', 'rootval');
const scope = effectScope();
let v: ReturnType<typeof useCssVar>;
scope.run(() => {
v = useCssVar('--root-var');
});
expect(v!.value).toBe('rootval');
v!.value = 'changed';
expect(document.documentElement.style.getPropertyValue('--root-var')).toBe('changed');
document.documentElement.style.removeProperty('--root-var');
scope.stop();
});
it('removes the old property and re-reads when the prop name changes', async () => {
const el = document.createElement('div');
el.style.setProperty('--a', 'one');
el.style.setProperty('--b', 'two');
document.body.appendChild(el);
const prop = ref('--a');
const scope = effectScope();
let v: ReturnType<typeof useCssVar>;
scope.run(() => {
v = useCssVar(prop, el);
});
expect(v!.value).toBe('one');
prop.value = '--b';
await nextTick();
expect(el.style.getPropertyValue('--a')).toBe('');
expect(v!.value).toBe('two');
scope.stop();
});
it('re-reads when a reactive target changes', async () => {
const a = document.createElement('div');
a.style.setProperty('--c', 'fromA');
const b = document.createElement('div');
b.style.setProperty('--c', 'fromB');
document.body.append(a, b);
const target = ref<HTMLElement>(a);
const scope = effectScope();
let v: ReturnType<typeof useCssVar>;
scope.run(() => {
v = useCssVar('--c', target);
});
expect(v!.value).toBe('fromA');
target.value = b;
await nextTick();
expect(v!.value).toBe('fromB');
scope.stop();
});
it('attaches a MutationObserver only when observe is true', () => {
vi.stubGlobal('MutationObserver', StubMutationObserver);
const el = document.createElement('div');
document.body.appendChild(el);
const scopeOff = effectScope();
scopeOff.run(() => useCssVar('--x', el));
expect(mutationInstances).toHaveLength(0);
scopeOff.stop();
const scopeOn = effectScope();
scopeOn.run(() => useCssVar('--x', el, { observe: true }));
expect(mutationInstances).toHaveLength(1);
expect(mutationInstances[0]!.observe).toHaveBeenCalledWith(
el,
expect.objectContaining({ attributeFilter: ['style', 'class'] }),
);
scopeOn.stop();
});
it('updates the ref when an observed mutation fires', () => {
vi.stubGlobal('MutationObserver', StubMutationObserver);
const el = document.createElement('div');
el.style.setProperty('--y', 'initial');
document.body.appendChild(el);
const scope = effectScope();
let v: ReturnType<typeof useCssVar>;
scope.run(() => {
v = useCssVar('--y', el, { observe: true });
});
expect(v!.value).toBe('initial');
// Simulate an external change followed by a mutation record.
el.style.setProperty('--y', 'external');
mutationInstances[0]!.cb([{ type: 'attributes' } as MutationRecord], mutationInstances[0] as unknown as MutationObserver);
expect(v!.value).toBe('external');
scope.stop();
});
it('does not throw and keeps initialValue under SSR (no window)', () => {
const scope = effectScope();
let v: ReturnType<typeof useCssVar>;
scope.run(() => {
v = useCssVar('--ssr', null, {
initialValue: 'fallback',
window: undefined as unknown as Window,
});
});
expect(v!.value).toBe('fallback');
// Writing is a no-op on the (missing) element but still updates the store.
v!.value = 'next';
expect(v!.value).toBe('next');
scope.stop();
});
it('uses a custom window from options for getComputedStyle', () => {
const el = document.createElement('div');
document.body.appendChild(el);
const getComputedStyle = vi.fn(() => ({
getPropertyValue: () => ' spaced ',
})) as unknown as Window['getComputedStyle'];
const fakeWindow = {
getComputedStyle,
document: { documentElement: el },
} as unknown as Window;
const scope = effectScope();
let v: ReturnType<typeof useCssVar>;
scope.run(() => {
v = useCssVar('--z', el, { window: fakeWindow });
});
expect(getComputedStyle).toHaveBeenCalledWith(el);
expect(v!.value).toBe('spaced');
scope.stop();
});
});
@@ -0,0 +1,112 @@
import { computed, shallowRef, toValue, watch } from 'vue';
import type { ComputedRef, MaybeRefOrGetter, WritableComputedRef } from 'vue';
import type { ConfigurableWindow } from '@/types';
import { defaultWindow } from '@/types';
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
import { unrefElement } from '@/composables/component/unrefElement';
import { useMutationObserver } from '@/composables/elements/useMutationObserver';
export interface UseCssVarOptions extends ConfigurableWindow {
/**
* Value used before the variable resolves (and the fallback when the
* computed value is empty).
*/
initialValue?: string;
/**
* Watch the target with a `MutationObserver` (filtered to `style`/`class`)
* so the ref reflects external changes to the variable.
*
* @default false
*/
observe?: boolean;
}
export interface UseCssVarReturn extends WritableComputedRef<string | null | undefined> {}
/**
* @name useCssVar
* @category Browser
* @description Read and write a CSS custom property on an element as a reactive ref.
* Defaults to `document.documentElement`. Set `observe` to react to external
* changes via a `MutationObserver`.
*
* @param {MaybeRefOrGetter<string | null | undefined>} prop The CSS variable name (e.g. `--color`)
* @param {MaybeComputedElementRef} [target] Element to read/write the variable on (defaults to `documentElement`)
* @param {UseCssVarOptions} [options={}] `initialValue`, `observe`, and a configurable `window`
* @returns {UseCssVarReturn} A writable ref; reading returns the current value, writing updates the property
*
* @example
* const color = useCssVar('--color', el);
* color.value = 'red';
*
* @example
* const theme = useCssVar('--theme', null, { initialValue: 'light', observe: true });
*
* @since 0.0.15
*/
export function useCssVar(
prop: MaybeRefOrGetter<string | null | undefined>,
target?: MaybeComputedElementRef,
options: UseCssVarOptions = {},
): UseCssVarReturn {
const { window = defaultWindow, initialValue, observe = false } = options;
// Backing store: only mutated on explicit reads / observed changes,
// so consumers reading `.value` never pay for `getComputedStyle`.
const store = shallowRef<string | null | undefined>(initialValue);
const elRef: ComputedRef<HTMLElement | SVGElement | undefined> = computed(
() => (unrefElement(target) as HTMLElement | SVGElement | undefined) ?? window?.document?.documentElement,
);
const read = (): void => {
const el = elRef.value;
const key = toValue(prop);
if (!el || !window || !key)
return;
const value = window.getComputedStyle(el).getPropertyValue(key)?.trim();
store.value = value || store.value || initialValue;
};
const write = (value: string | null | undefined): void => {
const el = elRef.value;
const key = toValue(prop);
store.value = value;
if (!el?.style || !key)
return;
if (value === null || value === undefined)
el.style.removeProperty(key);
else
el.style.setProperty(key, value);
};
if (observe) {
useMutationObserver(elRef, read, {
attributeFilter: ['style', 'class'],
window,
});
}
// Single watcher: when the element or prop changes, drop the old custom
// property and re-read the current value.
watch(
() => [elRef.value, toValue(prop)] as const,
([el, key], old) => {
const [oldEl, oldKey] = old ?? [];
if (oldEl?.style && oldKey && (oldEl !== el || oldKey !== key))
oldEl.style.removeProperty(oldKey);
read();
},
{ immediate: true },
);
return computed<string | null | undefined>({
get: () => store.value,
set: write,
});
}
@@ -0,0 +1,330 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick } from 'vue';
import { useDark } from '.';
type Listener = (event: { matches: boolean }) => void;
interface StubMql {
readonly matches: boolean;
media: string;
addEventListener: (type: string, cb: Listener) => void;
removeEventListener: (type: string, cb: Listener) => void;
dispatch: (value: boolean) => void;
}
function makeMql(initialMatches: boolean, media = ''): StubMql {
const listeners = new Set<Listener>();
let matches = initialMatches;
return {
get matches() {
return matches;
},
media,
addEventListener: (_: string, cb: Listener) => listeners.add(cb),
removeEventListener: (_: string, cb: Listener) => listeners.delete(cb),
dispatch(value: boolean) {
matches = value;
for (const cb of listeners) cb({ matches: value });
},
};
}
/**
* Build a stub `window` that reuses the real jsdom `document` (so DOM updates
* applied to `<html>` are observable) but with a controllable `matchMedia` for
* `prefers-color-scheme: dark`, an isolated in-memory `localStorage`, and a
* `getComputedStyle` shim for the transition-disabling reflow.
*/
function makeWindow(prefersDark: StubMql) {
const map = new Map<string, string>();
const storage: Storage = {
getItem: (key: string) => (map.has(key) ? map.get(key)! : null),
setItem: (key: string, value: string) => { map.set(key, String(value)); },
removeItem: (key: string) => { map.delete(key); },
clear: () => map.clear(),
key: (index: number) => [...map.keys()][index] ?? null,
get length() {
return map.size;
},
};
const win = {
document: globalThis.document,
matchMedia: vi.fn((query: string) =>
query.includes('dark') ? prefersDark : makeMql(false, query)),
localStorage: storage,
getComputedStyle: () => ({ opacity: '1' }),
dispatchEvent: () => true,
addEventListener: () => {},
removeEventListener: () => {},
} as unknown as Window & typeof globalThis;
return { win, storage, map };
}
function reset() {
document.documentElement.className = '';
document.documentElement.removeAttribute('data-theme');
}
describe(useDark, () => {
beforeEach(() => {
reset();
// Ensure module-captured defaultWindow.matchMedia is undefined so the
// composable must use the injected window.
vi.stubGlobal('matchMedia', undefined);
});
afterEach(() => {
vi.unstubAllGlobals();
reset();
});
it('reflects the system preference in auto mode (dark)', async () => {
const prefersDark = makeMql(true);
const { win } = makeWindow(prefersDark);
const scope = effectScope();
let isDark: ReturnType<typeof useDark>;
scope.run(() => {
isDark = useDark({ window: win });
});
await nextTick();
expect(isDark!.value).toBeTruthy();
expect(document.documentElement.classList.contains('dark')).toBeTruthy();
scope.stop();
});
it('reflects the system preference in auto mode (light)', async () => {
const prefersDark = makeMql(false);
const { win } = makeWindow(prefersDark);
const scope = effectScope();
let isDark: ReturnType<typeof useDark>;
scope.run(() => {
isDark = useDark({ window: win });
});
await nextTick();
expect(isDark!.value).toBeFalsy();
// Default valueLight is '' so no light class is applied.
expect(document.documentElement.classList.contains('dark')).toBeFalsy();
scope.stop();
});
it('writing true while the system prefers light applies the dark class', async () => {
const prefersDark = makeMql(false);
const { win } = makeWindow(prefersDark);
const scope = effectScope();
let isDark: ReturnType<typeof useDark>;
scope.run(() => {
isDark = useDark({ window: win });
});
await nextTick();
expect(isDark!.value).toBeFalsy();
isDark!.value = true;
await nextTick();
expect(isDark!.value).toBeTruthy();
expect(document.documentElement.classList.contains('dark')).toBeTruthy();
scope.stop();
});
it('writing true while the system prefers dark falls back to auto', async () => {
const prefersDark = makeMql(true);
const { win, storage } = makeWindow(prefersDark);
const scope = effectScope();
let isDark: ReturnType<typeof useDark>;
scope.run(() => {
// Start from an explicit light value so the store is not already auto.
isDark = useDark({ window: win });
});
await nextTick();
isDark!.value = false;
await nextTick();
expect(storage.getItem('vuetools-color-scheme')).toBe('light');
// System prefers dark, so requesting dark should resolve to 'auto'.
isDark!.value = true;
await nextTick();
expect(isDark!.value).toBeTruthy();
expect(storage.getItem('vuetools-color-scheme')).toBe('auto');
scope.stop();
});
it('writing false while the system prefers dark falls back to auto', async () => {
const prefersDark = makeMql(false);
const { win, storage } = makeWindow(prefersDark);
const scope = effectScope();
let isDark: ReturnType<typeof useDark>;
scope.run(() => {
isDark = useDark({ window: win });
});
await nextTick();
// System prefers light, so requesting light resolves to 'auto'.
isDark!.value = false;
await nextTick();
expect(isDark!.value).toBeFalsy();
expect(storage.getItem('vuetools-color-scheme')).toBe('auto');
scope.stop();
});
it('reacts to system preference changes while in auto mode', async () => {
const prefersDark = makeMql(false);
const { win } = makeWindow(prefersDark);
const scope = effectScope();
let isDark: ReturnType<typeof useDark>;
scope.run(() => {
isDark = useDark({ window: win });
});
await nextTick();
expect(isDark!.value).toBeFalsy();
prefersDark.dispatch(true);
await nextTick();
expect(isDark!.value).toBeTruthy();
expect(document.documentElement.classList.contains('dark')).toBeTruthy();
scope.stop();
});
it('honours custom valueDark / valueLight on a custom attribute', async () => {
const prefersDark = makeMql(false);
const { win } = makeWindow(prefersDark);
const scope = effectScope();
let isDark: ReturnType<typeof useDark>;
scope.run(() => {
isDark = useDark({
window: win,
attribute: 'data-theme',
valueDark: 'night',
valueLight: 'day',
});
});
await nextTick();
expect(document.documentElement.getAttribute('data-theme')).toBe('day');
isDark!.value = true;
await nextTick();
expect(document.documentElement.getAttribute('data-theme')).toBe('night');
scope.stop();
});
it('persists to a custom storageKey', async () => {
const prefersDark = makeMql(false);
const { win, storage } = makeWindow(prefersDark);
const scope = effectScope();
let isDark: ReturnType<typeof useDark>;
scope.run(() => {
isDark = useDark({ window: win, storageKey: 'my-dark' });
});
await nextTick();
isDark!.value = true;
await nextTick();
expect(storage.getItem('my-dark')).toBe('dark');
expect(storage.getItem('vuetools-color-scheme')).toBeNull();
scope.stop();
});
it('uses a custom storage backend', async () => {
const prefersDark = makeMql(false);
const { win } = makeWindow(prefersDark);
const map = new Map<string, string>();
const storage: Storage = {
getItem: (key: string) => (map.has(key) ? map.get(key)! : null),
setItem: (key: string, value: string) => { map.set(key, String(value)); },
removeItem: (key: string) => { map.delete(key); },
clear: () => map.clear(),
key: (index: number) => [...map.keys()][index] ?? null,
get length() {
return map.size;
},
};
const scope = effectScope();
let isDark: ReturnType<typeof useDark>;
scope.run(() => {
isDark = useDark({ window: win, storage });
});
await nextTick();
isDark!.value = true;
await nextTick();
expect(map.get('vuetools-color-scheme')).toBe('dark');
scope.stop();
});
it('invokes a custom onChanged handler with the boolean state', async () => {
const prefersDark = makeMql(true);
const { win } = makeWindow(prefersDark);
const onChanged = vi.fn();
const scope = effectScope();
scope.run(() => {
useDark({ window: win, onChanged });
});
await nextTick();
expect(onChanged).toHaveBeenCalled();
const [boolValue, handler, mode] = onChanged.mock.calls[0]!;
expect(boolValue).toBeTruthy();
expect(typeof handler).toBe('function');
expect(mode).toBe('dark');
// Default handler suppressed: no class applied.
expect(document.documentElement.classList.contains('dark')).toBeFalsy();
scope.stop();
});
it('does not throw on the SSR/unsupported path (no window)', async () => {
const scope = effectScope();
let isDark: ReturnType<typeof useDark>;
expect(() => {
scope.run(() => {
isDark = useDark({ window: undefined });
});
}).not.toThrow();
await nextTick();
// System detection unavailable -> defaults to light -> isDark false.
expect(isDark!.value).toBeFalsy();
// Still writable in memory.
isDark!.value = true;
await nextTick();
expect(isDark!.value).toBeTruthy();
scope.stop();
});
});
@@ -0,0 +1,97 @@
import { computed } from 'vue';
import type { WritableComputedRef } from 'vue';
import { useColorMode } from '@/composables/browser/useColorMode';
import type { BasicColorSchema, UseColorModeOptions } from '@/composables/browser/useColorMode';
export interface UseDarkOptions extends Omit<UseColorModeOptions<BasicColorSchema>, 'modes' | 'onChanged'> {
/**
* Value applied to the target element when `isDark` is `true`.
*
* @default 'dark'
*/
valueDark?: string;
/**
* Value applied to the target element when `isDark` is `false`.
*
* @default ''
*/
valueLight?: string;
/**
* Custom handler called whenever the resolved mode changes. When specified,
* the default DOM update is overridden (call `defaultHandler` to keep it).
*
* @default undefined
*/
onChanged?: (isDark: boolean, defaultHandler: (mode: BasicColorSchema) => void, mode: BasicColorSchema) => void;
}
export type UseDarkReturn = WritableComputedRef<boolean>;
/**
* @name useDark
* @category Browser
* @description Reactive dark mode boolean with system detection and storage
* persistence, built on `useColorMode`. Writing `false` while the system
* already prefers light (or `true` while it prefers dark) falls back to
* `'auto'`, so the mode keeps tracking the OS preference.
*
* @param {UseDarkOptions} [options={}] Options
* @returns {UseDarkReturn} A writable boolean ref; `true` when dark mode is active
*
* @example
* const isDark = useDark();
* isDark.value = true;
*
* @example
* // Toggle a data attribute instead of a class
* const isDark = useDark({
* attribute: 'data-theme',
* valueDark: 'dark',
* valueLight: 'light',
* });
*
* @example
* import { useToggle } from '@/composables/state/useToggle';
*
* const isDark = useDark();
* const toggleDark = useToggle(isDark);
*
* @since 0.0.15
*/
export function useDark(options: UseDarkOptions = {}): UseDarkReturn {
const {
valueDark = 'dark',
valueLight = '',
} = options;
const mode = useColorMode({
...options,
onChanged: (resolved, defaultHandler) => {
if (options.onChanged)
options.onChanged(resolved === 'dark', defaultHandler, resolved);
else
defaultHandler(resolved);
},
modes: {
dark: valueDark,
light: valueLight,
},
});
const isDark = computed<boolean>({
get() {
return mode.state.value === 'dark';
},
set(value) {
const next = value ? 'dark' : 'light';
// When the requested state already matches the system preference, fall
// back to `'auto'` so the mode keeps following the OS going forward.
mode.value = mode.system.value === next ? 'auto' : next;
},
});
return isDark;
}
@@ -1,164 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, isReadonly, nextTick } from 'vue';
import { useDevicePixelRatio } from '.';
type Listener = (event: { matches: boolean }) => void;
interface StubMql {
readonly matches: boolean;
media: string;
addEventListener: (type: string, cb: Listener) => void;
removeEventListener: (type: string, cb: Listener) => void;
dispatch: (value: boolean) => void;
}
function makeMql(media = ''): StubMql {
const listeners = new Set<Listener>();
let matches = true;
return {
get matches() {
return matches;
},
media,
addEventListener: (_: string, cb: Listener) => listeners.add(cb),
removeEventListener: (_: string, cb: Listener) => listeners.delete(cb),
dispatch(value: boolean) {
matches = value;
for (const cb of listeners) cb({ matches: value });
},
};
}
/** Returns one MediaQueryList per query string so re-binding can be exercised. */
function stubMatchMediaByQuery() {
const map = new Map<string, StubMql>();
const spy = vi.fn((query: string) => {
if (!map.has(query))
map.set(query, makeMql(query));
return map.get(query)!;
});
vi.stubGlobal('matchMedia', spy);
return { spy, map };
}
/** A minimal window stub whose `devicePixelRatio` can be mutated in tests. */
function makeWindowStub(initialRatio: number): Window & { devicePixelRatio: number } {
return {
devicePixelRatio: initialRatio,
matchMedia: globalThis.matchMedia,
} as unknown as Window & { devicePixelRatio: number };
}
describe(useDevicePixelRatio, () => {
beforeEach(() => {
vi.stubGlobal('matchMedia', undefined);
});
afterEach(() => vi.unstubAllGlobals());
it('reflects the initial devicePixelRatio', async () => {
stubMatchMediaByQuery();
const window = makeWindowStub(2);
const scope = effectScope();
let result: ReturnType<typeof useDevicePixelRatio>;
scope.run(() => {
result = useDevicePixelRatio({ window });
});
await nextTick();
expect(result!.pixelRatio.value).toBe(2);
scope.stop();
});
it('returns a readonly ref', async () => {
stubMatchMediaByQuery();
const window = makeWindowStub(1);
const scope = effectScope();
let result: ReturnType<typeof useDevicePixelRatio>;
scope.run(() => {
result = useDevicePixelRatio({ window });
});
await nextTick();
expect(isReadonly(result!.pixelRatio)).toBeTruthy();
scope.stop();
});
it('updates when the resolution media query flips', async () => {
const { map } = stubMatchMediaByQuery();
const window = makeWindowStub(1);
const scope = effectScope();
let result: ReturnType<typeof useDevicePixelRatio>;
scope.run(() => {
result = useDevicePixelRatio({ window });
});
await nextTick();
expect(result!.pixelRatio.value).toBe(1);
// Simulate a zoom: the real ratio changes, then the current query flips.
window.devicePixelRatio = 2;
map.get('(resolution: 1dppx)')!.dispatch(false);
await nextTick();
expect(result!.pixelRatio.value).toBe(2);
scope.stop();
});
it('re-binds the listener after the ratio changes', async () => {
const { spy, map } = stubMatchMediaByQuery();
const window = makeWindowStub(1);
const scope = effectScope();
let result: ReturnType<typeof useDevicePixelRatio>;
scope.run(() => {
result = useDevicePixelRatio({ window });
});
await nextTick();
window.devicePixelRatio = 3;
map.get('(resolution: 1dppx)')!.dispatch(false);
await nextTick();
expect(result!.pixelRatio.value).toBe(3);
// The query string should now track 3dppx (new MediaQueryList created).
expect(spy).toHaveBeenCalledWith('(resolution: 3dppx)');
// Further changes are driven by the new MediaQueryList.
window.devicePixelRatio = 1.5;
map.get('(resolution: 3dppx)')!.dispatch(false);
await nextTick();
expect(result!.pixelRatio.value).toBe(1.5);
scope.stop();
});
it('stops tracking after stop()', async () => {
const { map } = stubMatchMediaByQuery();
const window = makeWindowStub(1);
const scope = effectScope();
let result: ReturnType<typeof useDevicePixelRatio>;
scope.run(() => {
result = useDevicePixelRatio({ window });
});
await nextTick();
result!.stop();
window.devicePixelRatio = 2;
map.get('(resolution: 1dppx)')!.dispatch(false);
await nextTick();
expect(result!.pixelRatio.value).toBe(1);
scope.stop();
});
it('defaults to 1 with a no-op stop when no window is available (SSR)', async () => {
const scope = effectScope();
let result: ReturnType<typeof useDevicePixelRatio>;
scope.run(() => {
result = useDevicePixelRatio({ window: undefined });
});
await nextTick();
expect(result!.pixelRatio.value).toBe(1);
// stop() must be safe to call even when nothing was bound.
expect(() => result!.stop()).not.toThrow();
scope.stop();
});
});
@@ -1,63 +0,0 @@
import { noop } from '@robonen/stdlib';
import { shallowReadonly, shallowRef, watch } from 'vue';
import type { ShallowRef, WatchStopHandle } from 'vue';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { useMediaQuery } from '@/composables/browser/useMediaQuery';
export interface UseDevicePixelRatioOptions extends ConfigurableWindow {}
export interface UseDevicePixelRatioReturn {
/**
* Reactive, readonly `window.devicePixelRatio`. Defaults to `1` on the
* server / when no window is available.
*/
pixelRatio: Readonly<ShallowRef<number>>;
/**
* Stop tracking the device pixel ratio. Idempotent and a no-op on SSR.
*/
stop: WatchStopHandle;
}
/**
* @name useDevicePixelRatio
* @category Browser
* @description Reactively track `window.devicePixelRatio`, updated via a
* `matchMedia(resolution)` listener (fires on zoom and on monitor changes).
*
* @param {UseDevicePixelRatioOptions} [options={}] Options (custom `window`)
* @returns {UseDevicePixelRatioReturn} `{ pixelRatio, stop }`
*
* @example
* const { pixelRatio } = useDevicePixelRatio();
*
* @since 0.0.15
*/
export function useDevicePixelRatio(options: UseDevicePixelRatioOptions = {}): UseDevicePixelRatioReturn {
const { window = defaultWindow } = options;
const pixelRatio = shallowRef(1);
// `devicePixelRatio` has no `change` event; the canonical trick is to watch a
// `(resolution: Ndppx)` media query whose threshold tracks the current ratio.
// When the real ratio crosses that threshold the query flips, re-evaluating
// the reactive query string and re-binding to a fresh MediaQueryList.
const query = useMediaQuery(() => `(resolution: ${pixelRatio.value}dppx)`, options);
let stop: WatchStopHandle = noop;
if (window) {
stop = watch(
query,
() => {
pixelRatio.value = window.devicePixelRatio;
},
{ immediate: true },
);
}
return {
pixelRatio: shallowReadonly(pixelRatio),
stop,
};
}
@@ -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,
};
}
@@ -1,143 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick } from 'vue';
import { useDocumentReadyState } from '.';
afterEach(() => {
vi.unstubAllGlobals();
Object.defineProperty(document, 'readyState', { value: 'complete', configurable: true });
});
function setReadyState(state: DocumentReadyState) {
Object.defineProperty(document, 'readyState', { value: state, configurable: true });
document.dispatchEvent(new Event('readystatechange'));
}
describe(useDocumentReadyState, () => {
it('reads the current ready state', () => {
const scope = effectScope();
let readyState: ReturnType<typeof useDocumentReadyState>;
scope.run(() => {
readyState = useDocumentReadyState();
});
expect(readyState!.value).toBe('complete');
scope.stop();
});
it('reflects a non-default initial state at setup time', () => {
Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true });
const scope = effectScope();
let readyState: ReturnType<typeof useDocumentReadyState>;
scope.run(() => {
readyState = useDocumentReadyState();
});
expect(readyState!.value).toBe('loading');
scope.stop();
});
it('updates on readystatechange', async () => {
Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true });
const scope = effectScope();
let readyState: ReturnType<typeof useDocumentReadyState>;
scope.run(() => {
readyState = useDocumentReadyState();
});
expect(readyState!.value).toBe('loading');
setReadyState('interactive');
await nextTick();
expect(readyState!.value).toBe('interactive');
setReadyState('complete');
await nextTick();
expect(readyState!.value).toBe('complete');
scope.stop();
});
it('invokes onChange with new state, previous state, and the event', async () => {
Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true });
const onChange = vi.fn();
const scope = effectScope();
scope.run(() => {
useDocumentReadyState({ onChange });
});
setReadyState('interactive');
await nextTick();
expect(onChange).toHaveBeenCalledTimes(1);
const [state, previous, event] = onChange.mock.calls[0]!;
expect(state).toBe('interactive');
expect(previous).toBe('loading');
expect(event).toBeInstanceOf(Event);
setReadyState('complete');
await nextTick();
expect(onChange).toHaveBeenCalledTimes(2);
expect(onChange.mock.calls[1]!.slice(0, 2)).toEqual(['complete', 'interactive']);
scope.stop();
});
it('does not update or fire onChange when the state is unchanged', async () => {
const onChange = vi.fn();
const scope = effectScope();
let readyState: ReturnType<typeof useDocumentReadyState>;
scope.run(() => {
readyState = useDocumentReadyState({ onChange });
});
// readyState is already 'complete'; dispatching with no real change is a no-op
document.dispatchEvent(new Event('readystatechange'));
await nextTick();
expect(onChange).not.toHaveBeenCalled();
expect(readyState!.value).toBe('complete');
scope.stop();
});
it('is SSR-safe and returns "loading" without a document', () => {
// Passing `document: undefined` resolves to the default document, so to exercise the
// no-document branch we cast a falsy value that bypasses the default-parameter logic.
const scope = effectScope();
let readyState: ReturnType<typeof useDocumentReadyState>;
scope.run(() => {
readyState = useDocumentReadyState({ document: null as unknown as Document });
});
expect(readyState!.value).toBe('loading');
scope.stop();
});
it('accepts a custom document instance', async () => {
const onChange = vi.fn();
let listener: ((event: Event) => void) | undefined;
const customDoc = {
readyState: 'loading' as DocumentReadyState,
addEventListener: (_type: string, cb: (event: Event) => void) => { listener = cb; },
removeEventListener: vi.fn(),
} as unknown as Document;
const scope = effectScope();
let readyState: ReturnType<typeof useDocumentReadyState>;
scope.run(() => {
readyState = useDocumentReadyState({ document: customDoc, onChange });
});
expect(readyState!.value).toBe('loading');
(customDoc as { readyState: DocumentReadyState }).readyState = 'complete';
listener?.(new Event('readystatechange'));
await nextTick();
expect(readyState!.value).toBe('complete');
expect(onChange).toHaveBeenCalledWith('complete', 'loading', expect.any(Event));
scope.stop();
});
});
@@ -1,67 +0,0 @@
import { shallowRef } from 'vue';
import type { ShallowRef } from 'vue';
import { defaultDocument } from '@/types';
import type { ConfigurableDocument } from '@/types';
import { useEventListener } from '@/composables/browser/useEventListener';
export interface UseDocumentReadyStateOptions extends ConfigurableDocument {
/**
* Called whenever `document.readyState` changes, receiving the new state,
* the previous state, and the originating `readystatechange` event.
*
* @default undefined
*/
onChange?: (
state: DocumentReadyState,
previous: DocumentReadyState,
event: Event,
) => void;
}
export type UseDocumentReadyStateReturn = ShallowRef<DocumentReadyState>;
/**
* @name useDocumentReadyState
* @category Browser
* @description Reactive `document.readyState` (`loading` | `interactive` | `complete`), updated on `readystatechange`.
*
* @param {UseDocumentReadyStateOptions} [options={}] Options (custom `document`, `onChange` callback)
* @returns {UseDocumentReadyStateReturn} The current document ready state
*
* @example
* const readyState = useDocumentReadyState();
* watch(readyState, (state) => {
* if (state === 'complete') runAfterLoad();
* });
*
* @example
* useDocumentReadyState({
* onChange: (state) => {
* if (state === 'interactive') hydrate();
* },
* });
*
* @since 0.0.15
*/
export function useDocumentReadyState(
options: UseDocumentReadyStateOptions = {},
): UseDocumentReadyStateReturn {
const { document = defaultDocument, onChange } = options;
const readyState = shallowRef<DocumentReadyState>(document?.readyState ?? 'loading');
if (document) {
useEventListener(document, 'readystatechange', (event) => {
const previous = readyState.value;
const state = document.readyState;
if (state === previous)
return;
readyState.value = state;
onChange?.(state, previous, event);
}, { passive: true });
}
return readyState;
}
@@ -1,131 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick } from 'vue';
import { useDocumentVisibility } from '.';
afterEach(() => {
vi.unstubAllGlobals();
Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true });
});
function setVisibility(state: DocumentVisibilityState) {
Object.defineProperty(document, 'visibilityState', { value: state, configurable: true });
document.dispatchEvent(new Event('visibilitychange'));
}
describe(useDocumentVisibility, () => {
it('reads the current visibility state', () => {
const scope = effectScope();
let visibility: ReturnType<typeof useDocumentVisibility>;
scope.run(() => {
visibility = useDocumentVisibility();
});
expect(visibility!.value).toBe('visible');
scope.stop();
});
it('updates on visibilitychange', async () => {
const scope = effectScope();
let visibility: ReturnType<typeof useDocumentVisibility>;
scope.run(() => {
visibility = useDocumentVisibility();
});
setVisibility('hidden');
await nextTick();
expect(visibility!.value).toBe('hidden');
scope.stop();
});
it('invokes onChange with new state, previous state, and the event', async () => {
const onChange = vi.fn();
const scope = effectScope();
scope.run(() => {
useDocumentVisibility({ onChange });
});
setVisibility('hidden');
await nextTick();
expect(onChange).toHaveBeenCalledTimes(1);
const [state, previous, event] = onChange.mock.calls[0]!;
expect(state).toBe('hidden');
expect(previous).toBe('visible');
expect(event).toBeInstanceOf(Event);
setVisibility('visible');
await nextTick();
expect(onChange).toHaveBeenCalledTimes(2);
expect(onChange.mock.calls[1]!.slice(0, 2)).toEqual(['visible', 'hidden']);
scope.stop();
});
it('does not update or fire onChange when the state is unchanged', async () => {
const onChange = vi.fn();
const scope = effectScope();
let visibility: ReturnType<typeof useDocumentVisibility>;
scope.run(() => {
visibility = useDocumentVisibility({ onChange });
});
// visibilityState is already 'visible'; dispatching with no real change is a no-op
document.dispatchEvent(new Event('visibilitychange'));
await nextTick();
expect(onChange).not.toHaveBeenCalled();
expect(visibility!.value).toBe('visible');
scope.stop();
});
it('reflects a non-default initial state at setup time', () => {
Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
const scope = effectScope();
let visibility: ReturnType<typeof useDocumentVisibility>;
scope.run(() => {
visibility = useDocumentVisibility();
});
expect(visibility!.value).toBe('hidden');
scope.stop();
});
it('is SSR-safe and returns "visible" without a document', () => {
const scope = effectScope();
let visibility: ReturnType<typeof useDocumentVisibility>;
scope.run(() => {
visibility = useDocumentVisibility({ document: undefined });
});
expect(visibility!.value).toBe('visible');
scope.stop();
});
it('accepts a custom document instance', async () => {
const onChange = vi.fn();
let listener: ((event: Event) => void) | undefined;
const customDoc = {
visibilityState: 'visible' as DocumentVisibilityState,
addEventListener: (_type: string, cb: (event: Event) => void) => { listener = cb; },
removeEventListener: vi.fn(),
} as unknown as Document;
const scope = effectScope();
let visibility: ReturnType<typeof useDocumentVisibility>;
scope.run(() => {
visibility = useDocumentVisibility({ document: customDoc, onChange });
});
expect(visibility!.value).toBe('visible');
(customDoc as { visibilityState: DocumentVisibilityState }).visibilityState = 'hidden';
listener?.(new Event('visibilitychange'));
await nextTick();
expect(visibility!.value).toBe('hidden');
expect(onChange).toHaveBeenCalledWith('hidden', 'visible', expect.any(Event));
scope.stop();
});
});
@@ -1,67 +0,0 @@
import { shallowRef } from 'vue';
import type { ShallowRef } from 'vue';
import { defaultDocument } from '@/types';
import type { ConfigurableDocument } from '@/types';
import { useEventListener } from '@/composables/browser/useEventListener';
export interface UseDocumentVisibilityOptions extends ConfigurableDocument {
/**
* Called whenever `document.visibilityState` changes, receiving the new state,
* the previous state, and the originating `visibilitychange` event.
*
* @default undefined
*/
onChange?: (
state: DocumentVisibilityState,
previous: DocumentVisibilityState,
event: Event,
) => void;
}
export type UseDocumentVisibilityReturn = ShallowRef<DocumentVisibilityState>;
/**
* @name useDocumentVisibility
* @category Browser
* @description Reactive `document.visibilityState`.
*
* @param {UseDocumentVisibilityOptions} [options={}] Options (custom `document`, `onChange` callback)
* @returns {UseDocumentVisibilityReturn} The current visibility state
*
* @example
* const visibility = useDocumentVisibility();
* watch(visibility, (state) => {
* if (state === 'visible') refresh();
* });
*
* @example
* useDocumentVisibility({
* onChange: (state) => {
* if (state === 'hidden') pausePlayback();
* },
* });
*
* @since 0.0.15
*/
export function useDocumentVisibility(
options: UseDocumentVisibilityOptions = {},
): UseDocumentVisibilityReturn {
const { document = defaultDocument, onChange } = options;
const visibility = shallowRef<DocumentVisibilityState>(document?.visibilityState ?? 'visible');
if (document) {
useEventListener(document, 'visibilitychange', (event) => {
const previous = visibility.value;
const state = document.visibilityState;
if (state === previous)
return;
visibility.value = state;
onChange?.(state, previous, event);
}, { passive: true });
}
return visibility;
}
@@ -1,288 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, ref } from 'vue';
import { useDropZone } from '.';
interface FakeDataTransfer {
files: File[];
items: Array<{ type: string }>;
dropEffect: string;
}
function makeFile(name = 'a.png', type = 'image/png'): File {
return new File(['x'], name, { type });
}
// jsdom lacks DragEvent / DataTransfer, so we synthesize an Event with a dataTransfer payload.
function dispatchDrag(
el: EventTarget,
type: 'dragenter' | 'dragover' | 'dragleave' | 'drop',
files: File[] = [],
): { event: Event; dataTransfer: FakeDataTransfer } {
const dataTransfer: FakeDataTransfer = {
files,
items: files.map(f => ({ type: f.type })),
dropEffect: 'none',
};
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperty(event, 'dataTransfer', { value: dataTransfer, configurable: true });
el.dispatchEvent(event);
return { event, dataTransfer };
}
describe(useDropZone, () => {
let el: HTMLElement;
beforeEach(() => {
el = document.createElement('div');
document.body.appendChild(el);
});
afterEach(() => {
el.remove();
vi.unstubAllGlobals();
});
it('exposes reactive state', () => {
const scope = effectScope();
scope.run(() => {
const { isOverDropZone, files, isSupported } = useDropZone(el);
expect(isOverDropZone.value).toBeFalsy();
expect(files.value).toBeNull();
expect(isSupported).toBeDefined();
});
scope.stop();
});
it('sets isOverDropZone on dragenter and clears on matching dragleave', () => {
const scope = effectScope();
scope.run(() => {
const { isOverDropZone } = useDropZone(el);
dispatchDrag(el, 'dragenter', [makeFile()]);
expect(isOverDropZone.value).toBeTruthy();
dispatchDrag(el, 'dragleave', [makeFile()]);
expect(isOverDropZone.value).toBeFalsy();
});
scope.stop();
});
it('uses a counter so nested enter/leave keeps isOverDropZone true', () => {
const scope = effectScope();
scope.run(() => {
const { isOverDropZone } = useDropZone(el);
dispatchDrag(el, 'dragenter', [makeFile()]);
dispatchDrag(el, 'dragenter', [makeFile()]);
expect(isOverDropZone.value).toBeTruthy();
dispatchDrag(el, 'dragleave', [makeFile()]);
expect(isOverDropZone.value).toBeTruthy();
dispatchDrag(el, 'dragleave', [makeFile()]);
expect(isOverDropZone.value).toBeFalsy();
});
scope.stop();
});
it('collects dropped files and resets isOverDropZone', () => {
const scope = effectScope();
scope.run(() => {
const { files, isOverDropZone } = useDropZone(el);
dispatchDrag(el, 'dragenter', [makeFile()]);
const dropped = [makeFile('one.png'), makeFile('two.png')];
dispatchDrag(el, 'drop', dropped);
expect(files.value).toHaveLength(2);
expect(files.value?.[0]!.name).toBe('one.png');
expect(isOverDropZone.value).toBeFalsy();
});
scope.stop();
});
it('invokes lifecycle callbacks', () => {
const scope = effectScope();
scope.run(() => {
const onEnter = vi.fn();
const onOver = vi.fn();
const onLeave = vi.fn();
const onDrop = vi.fn();
useDropZone(el, { onEnter, onOver, onLeave, onDrop });
const f = [makeFile()];
dispatchDrag(el, 'dragenter', f);
dispatchDrag(el, 'dragover', f);
dispatchDrag(el, 'dragleave', f);
dispatchDrag(el, 'drop', f);
expect(onEnter).toHaveBeenCalledTimes(1);
expect(onOver).toHaveBeenCalledTimes(1);
expect(onLeave).toHaveBeenCalledTimes(1);
expect(onDrop).toHaveBeenCalledTimes(1);
expect(onEnter).toHaveBeenCalledWith(null, expect.any(Event));
expect(onDrop.mock.calls[0]![0]).toHaveLength(1);
});
scope.stop();
});
it('accepts a shorthand onDrop function as options', () => {
const scope = effectScope();
scope.run(() => {
const onDrop = vi.fn();
useDropZone(el, onDrop);
dispatchDrag(el, 'drop', [makeFile()]);
expect(onDrop).toHaveBeenCalledTimes(1);
});
scope.stop();
});
it('respects multiple: false by keeping only the first file', () => {
const scope = effectScope();
scope.run(() => {
const { files } = useDropZone(el, { multiple: false });
// Two files dragged: validation should reject, so drop is ignored
dispatchDrag(el, 'drop', [makeFile('a.png'), makeFile('b.png')]);
expect(files.value).toBeNull();
// Single file passes and only the first is kept
dispatchDrag(el, 'drop', [makeFile('solo.png')]);
expect(files.value).toHaveLength(1);
expect(files.value?.[0]!.name).toBe('solo.png');
});
scope.stop();
});
it('filters by dataTypes array', () => {
const scope = effectScope();
scope.run(() => {
const onDrop = vi.fn();
const { files } = useDropZone(el, { dataTypes: ['image/png'], onDrop });
// wrong type rejected
dispatchDrag(el, 'drop', [makeFile('doc.pdf', 'application/pdf')]);
expect(files.value).toBeNull();
expect(onDrop).not.toHaveBeenCalled();
// correct type accepted
dispatchDrag(el, 'drop', [makeFile('img.png', 'image/png')]);
expect(files.value).toHaveLength(1);
expect(onDrop).toHaveBeenCalledTimes(1);
});
scope.stop();
});
it('supports dataTypes as a predicate function', () => {
const scope = effectScope();
scope.run(() => {
const predicate = vi.fn((types: readonly string[]) => types.includes('image/png'));
const { files } = useDropZone(el, { dataTypes: predicate });
dispatchDrag(el, 'drop', [makeFile('img.png', 'image/png')]);
expect(predicate).toHaveBeenCalled();
expect(files.value).toHaveLength(1);
});
scope.stop();
});
it('reacts to a reactive dataTypes ref', () => {
const scope = effectScope();
scope.run(() => {
const allowed = ref<string[]>(['image/png']);
const { files } = useDropZone(el, { dataTypes: allowed });
dispatchDrag(el, 'drop', [makeFile('doc.pdf', 'application/pdf')]);
expect(files.value).toBeNull();
allowed.value = ['application/pdf'];
dispatchDrag(el, 'drop', [makeFile('doc.pdf', 'application/pdf')]);
expect(files.value).toHaveLength(1);
});
scope.stop();
});
it('sets dropEffect to none for invalid drags', () => {
const scope = effectScope();
scope.run(() => {
useDropZone(el, { dataTypes: ['image/png'] });
const { dataTransfer } = dispatchDrag(el, 'dragenter', [makeFile('doc.pdf', 'application/pdf')]);
expect(dataTransfer.dropEffect).toBe('none');
});
scope.stop();
});
it('sets dropEffect to copy for valid drags', () => {
const scope = effectScope();
scope.run(() => {
useDropZone(el, { dataTypes: ['image/png'] });
const { dataTransfer } = dispatchDrag(el, 'dragenter', [makeFile('img.png', 'image/png')]);
expect(dataTransfer.dropEffect).toBe('copy');
});
scope.stop();
});
it('preventDefaultForUnhandled calls preventDefault on invalid drags', () => {
const scope = effectScope();
scope.run(() => {
useDropZone(el, { dataTypes: ['image/png'], preventDefaultForUnhandled: true });
const { event } = dispatchDrag(el, 'dragenter', [makeFile('doc.pdf', 'application/pdf')]);
expect(event.defaultPrevented).toBeTruthy();
});
scope.stop();
});
it('works with a reactive element ref target', async () => {
const scope = effectScope();
await scope.run(async () => {
const target = ref<HTMLElement | null>(null);
const { isOverDropZone } = useDropZone(target);
target.value = el;
await nextTick();
dispatchDrag(el, 'dragenter', [makeFile()]);
expect(isOverDropZone.value).toBeTruthy();
});
scope.stop();
});
it('works with document as the target', () => {
const scope = effectScope();
scope.run(() => {
const { isOverDropZone } = useDropZone(document);
dispatchDrag(document, 'dragenter', [makeFile()]);
expect(isOverDropZone.value).toBeTruthy();
});
scope.stop();
});
it('stops listening after the scope is disposed', () => {
const onDrop = vi.fn();
const scope = effectScope();
scope.run(() => {
useDropZone(el, { onDrop });
});
scope.stop();
dispatchDrag(el, 'drop', [makeFile()]);
expect(onDrop).not.toHaveBeenCalled();
});
it('reports isSupported via the configurable window option', () => {
const scope = effectScope();
scope.run(() => {
const { isSupported } = useDropZone(el, { window: undefined });
expect(isSupported.value).toBeFalsy();
});
scope.stop();
});
});
@@ -1,205 +0,0 @@
import type { ComputedRef, MaybeRef, MaybeRefOrGetter, ShallowRef } from 'vue';
import { shallowRef, toValue, unref } from 'vue';
import { isFunction } from '@robonen/stdlib';
import { useEventListener } from '@/composables/browser/useEventListener';
import { useSupported } from '@/composables/browser/useSupported';
import { unrefElement } from '@/composables/component/unrefElement';
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
import { defaultNavigator, defaultWindow } from '@/types';
import type { ConfigurableNavigator, ConfigurableWindow } from '@/types';
export type UseDropZoneDataTypes = MaybeRef<readonly string[]> | ((types: readonly string[]) => boolean);
export interface UseDropZoneOptions extends ConfigurableWindow, ConfigurableNavigator {
/**
* Allowed data types. If not set, all data types are allowed.
* Can also be a predicate that receives the dragged item types and returns whether they are valid.
*/
dataTypes?: UseDropZoneDataTypes;
/**
* Allow multiple files to be dropped.
*
* @default true
*/
multiple?: boolean;
/**
* Call `preventDefault` even for drags that fail validation, suppressing the browser's default handling.
*
* @default false
*/
preventDefaultForUnhandled?: boolean;
/**
* Fired when valid files are dropped on the target.
*/
onDrop?: (files: File[] | null, event: DragEvent) => void;
/**
* Fired when a drag enters the target.
*/
onEnter?: (files: File[] | null, event: DragEvent) => void;
/**
* Fired when a drag leaves the target.
*/
onLeave?: (files: File[] | null, event: DragEvent) => void;
/**
* Fired repeatedly while a drag hovers over the target.
*/
onOver?: (files: File[] | null, event: DragEvent) => void;
}
export interface UseDropZoneReturn {
/**
* Whether a valid drag is currently hovering over the target.
*/
isOverDropZone: ShallowRef<boolean>;
/**
* The dropped files, or `null` when nothing has been dropped yet.
*/
files: ShallowRef<File[] | null>;
/**
* Whether the Drag and Drop API is available in the current environment.
*/
isSupported: ComputedRef<boolean>;
}
type DropZoneEventType = 'enter' | 'over' | 'leave' | 'drop';
/**
* @name useDropZone
* @category Browser
* @description Create a drag-and-drop file drop zone on a target element or document.
*
* @param {MaybeComputedElementRef | MaybeRefOrGetter<Document | null | undefined>} target - The element (or document) acting as the drop zone.
* @param {UseDropZoneOptions | UseDropZoneOptions['onDrop']} [options] - Drop zone options, or a shorthand `onDrop` callback.
* @returns {UseDropZoneReturn} The reactive drop zone state.
*
* @example
* const dropZone = useTemplateRef<HTMLElement>('dropZone');
* const { isOverDropZone, files } = useDropZone(dropZone, {
* dataTypes: ['image/png'],
* onDrop: (files) => console.log(files),
* });
*
* @since 0.0.15
*/
export function useDropZone(
target: MaybeComputedElementRef | MaybeRefOrGetter<Document | null | undefined>,
options: UseDropZoneOptions | UseDropZoneOptions['onDrop'] = {},
): UseDropZoneReturn {
const _options: UseDropZoneOptions = isFunction(options) ? { onDrop: options } : options;
const {
window = defaultWindow,
navigator = defaultNavigator,
multiple = true,
preventDefaultForUnhandled = false,
} = _options;
const isOverDropZone = shallowRef(false);
const files = shallowRef<File[] | null>(null);
const isSupported = useSupported(() => window && 'DataTransfer' in window);
let counter = 0;
let isValid = true;
const getFiles = (event: DragEvent): File[] | null => {
const list = Array.from(event.dataTransfer?.files ?? []);
if (list.length === 0)
return null;
return multiple ? list : [list[0]!];
};
const checkDataTypes = (types: readonly string[]): boolean => {
// `dataTypes` may be a predicate function, so unwrap with `unref` (not `toValue`,
// which would call a function as a getter).
const dataTypes = unref(_options.dataTypes);
if (isFunction(dataTypes))
return dataTypes(types);
if (!dataTypes?.length)
return true;
if (types.length === 0)
return false;
return types.every(type => dataTypes.some(allowed => type.includes(allowed)));
};
const checkValidity = (items: DataTransferItemList): boolean => {
const types = Array.from(items ?? []).map(item => item.type);
const dataTypesValid = checkDataTypes(types);
const multipleFilesValid = multiple || items.length <= 1;
return dataTypesValid && multipleFilesValid;
};
// Safari fires drag events without populating `dataTransfer.items`, so validation
// cannot be trusted there — always accept the drag and let `drop` resolve files.
const isSafari = (): boolean => {
if (!navigator || !window)
return false;
return /^(?:(?!chrome|android).)*safari/i.test(navigator.userAgent) && !('chrome' in window);
};
const handleDragEvent = (event: DragEvent, type: DropZoneEventType): void => {
const items = event.dataTransfer?.items;
isValid = (items && checkValidity(items)) ?? false;
if (preventDefaultForUnhandled)
event.preventDefault();
if (!isSafari() && !isValid) {
if (event.dataTransfer)
event.dataTransfer.dropEffect = 'none';
return;
}
event.preventDefault();
if (event.dataTransfer)
event.dataTransfer.dropEffect = 'copy';
const currentFiles = getFiles(event);
switch (type) {
case 'enter':
counter += 1;
isOverDropZone.value = true;
_options.onEnter?.(null, event);
break;
case 'over':
_options.onOver?.(null, event);
break;
case 'leave':
counter -= 1;
if (counter === 0)
isOverDropZone.value = false;
_options.onLeave?.(null, event);
break;
case 'drop':
counter = 0;
isOverDropZone.value = false;
if (isValid) {
files.value = currentFiles;
_options.onDrop?.(currentFiles, event);
}
break;
}
};
const resolveTarget = (): EventTarget | null | undefined => {
const value = toValue(target as MaybeRefOrGetter<unknown>);
if (value instanceof Document)
return value;
return unrefElement(target as MaybeComputedElementRef);
};
useEventListener<DragEvent>(resolveTarget, 'dragenter', event => handleDragEvent(event, 'enter'));
useEventListener<DragEvent>(resolveTarget, 'dragover', event => handleDragEvent(event, 'over'));
useEventListener<DragEvent>(resolveTarget, 'dragleave', event => handleDragEvent(event, 'leave'));
useEventListener<DragEvent>(resolveTarget, 'drop', event => handleDragEvent(event, 'drop'));
return {
isOverDropZone,
files,
isSupported,
};
}
@@ -1,153 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, ref } from 'vue';
import { useElementBounding } from '.';
class StubObserver {
observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
takeRecords = vi.fn(() => []);
}
describe(useElementBounding, () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', StubObserver);
vi.stubGlobal('MutationObserver', StubObserver);
});
afterEach(() => vi.unstubAllGlobals());
it('reads the bounding rect immediately', () => {
const el = document.createElement('div');
el.getBoundingClientRect = () => ({
width: 100, height: 50, top: 10, left: 20, right: 120, bottom: 60, x: 20, y: 10,
} as DOMRect);
const scope = effectScope();
let bounds: ReturnType<typeof useElementBounding>;
scope.run(() => {
bounds = useElementBounding(ref(el));
});
expect(bounds!.width.value).toBe(100);
expect(bounds!.height.value).toBe(50);
expect(bounds!.top.value).toBe(10);
expect(bounds!.left.value).toBe(20);
expect(bounds!.x.value).toBe(20);
expect(bounds!.y.value).toBe(10);
scope.stop();
});
it('update recomputes the rect', () => {
const el = document.createElement('div');
let w = 10;
el.getBoundingClientRect = () => ({ width: w, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0 } as DOMRect);
const scope = effectScope();
let bounds: ReturnType<typeof useElementBounding>;
scope.run(() => {
bounds = useElementBounding(ref(el));
});
expect(bounds!.width.value).toBe(10);
w = 200;
bounds!.update();
expect(bounds!.width.value).toBe(200);
scope.stop();
});
it('resets to zero when target is null', () => {
const scope = effectScope();
let bounds: ReturnType<typeof useElementBounding>;
scope.run(() => {
bounds = useElementBounding(ref(null));
});
expect(bounds!.width.value).toBe(0);
expect(bounds!.height.value).toBe(0);
scope.stop();
});
// NOTE: defaultWindow is captured at import time, so vi.stubGlobal does not
// reach requestAnimationFrame. We inject a fake window via the `window` option.
it('defers measurement to the next frame with updateTiming "next-frame"', () => {
const raf = vi.fn((cb: FrameRequestCallback) => {
cb(0);
return 1;
});
const fakeWindow = { requestAnimationFrame: raf, cancelAnimationFrame: vi.fn() } as unknown as Window;
const el = document.createElement('div');
let w = 10;
el.getBoundingClientRect = () => ({ width: w, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0 } as DOMRect);
const scope = effectScope();
let bounds: ReturnType<typeof useElementBounding>;
scope.run(() => {
bounds = useElementBounding(ref(el), { updateTiming: 'next-frame', window: fakeWindow });
});
// The immediate update went through requestAnimationFrame
expect(raf).toHaveBeenCalled();
expect(bounds!.width.value).toBe(10);
w = 200;
bounds!.update();
expect(bounds!.width.value).toBe(200);
scope.stop();
});
it('coalesces multiple "next-frame" updates into a single read per frame', () => {
let scheduled: FrameRequestCallback | undefined;
const raf = vi.fn((cb: FrameRequestCallback) => {
scheduled = cb;
return 1;
});
const fakeWindow = { requestAnimationFrame: raf, cancelAnimationFrame: vi.fn() } as unknown as Window;
const el = document.createElement('div');
const getRect = vi.fn(() => ({ width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0 } as DOMRect));
el.getBoundingClientRect = getRect;
const scope = effectScope();
let bounds: ReturnType<typeof useElementBounding>;
scope.run(() => {
bounds = useElementBounding(ref(el), { updateTiming: 'next-frame', immediate: false, window: fakeWindow });
});
bounds!.update();
bounds!.update();
bounds!.update();
// Only one frame was scheduled despite three update() calls
expect(raf).toHaveBeenCalledTimes(1);
expect(getRect).not.toHaveBeenCalled();
// Flushing the frame reads the rect exactly once
scheduled!(0);
expect(getRect).toHaveBeenCalledTimes(1);
// A new update after the frame flushed schedules a fresh frame
bounds!.update();
expect(raf).toHaveBeenCalledTimes(2);
scope.stop();
});
it('cancels a pending frame on scope dispose', () => {
const raf = vi.fn(() => 42);
const caf = vi.fn();
const fakeWindow = { requestAnimationFrame: raf, cancelAnimationFrame: caf } as unknown as Window;
const el = document.createElement('div');
el.getBoundingClientRect = () => ({ width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0 } as DOMRect);
const scope = effectScope();
scope.run(() => {
useElementBounding(ref(el), { updateTiming: 'next-frame', window: fakeWindow });
});
// The immediate update scheduled a frame that never ran (raf returns id without invoking)
expect(raf).toHaveBeenCalled();
scope.stop();
expect(caf).toHaveBeenCalledWith(42);
});
});
@@ -1,199 +0,0 @@
import { shallowRef, watch } from 'vue';
import type { Ref } from 'vue';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
import { unrefElement } from '@/composables/component/unrefElement';
import { useEventListener } from '@/composables/browser/useEventListener';
import { useResizeObserver } from '@/composables/browser/useResizeObserver';
import { useMutationObserver } from '@/composables/browser/useMutationObserver';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
export interface UseElementBoundingOptions extends ConfigurableWindow {
/**
* Reset values to 0 when the element is unmounted
*
* @default true
*/
reset?: boolean;
/**
* Recalculate on window resize
*
* @default true
*/
windowResize?: boolean;
/**
* Recalculate on window scroll
*
* @default true
*/
windowScroll?: boolean;
/**
* Calculate immediately on mount
*
* @default true
*/
immediate?: boolean;
/**
* When to recalculate the bounding box.
*
* - `'sync'` measures synchronously, the moment a trigger fires.
* - `'next-frame'` defers measurement to the next animation frame. This
* batches bursts of triggers (e.g. rapid scroll/resize) into a single
* read per frame, avoiding repeated layout thrash from `getBoundingClientRect`.
*
* @default 'sync'
*/
updateTiming?: 'sync' | 'next-frame';
}
export interface UseElementBoundingReturn {
height: Ref<number>;
width: Ref<number>;
top: Ref<number>;
right: Ref<number>;
bottom: Ref<number>;
left: Ref<number>;
x: Ref<number>;
y: Ref<number>;
/**
* Manually recalculate the bounding box, honouring `updateTiming`.
*/
update: () => void;
}
/**
* @name useElementBounding
* @category Browser
* @description Reactive bounding box of an element (`getBoundingClientRect`),
* kept in sync via `ResizeObserver`, `MutationObserver`, and window scroll/resize.
* Supports deferring reads to the next animation frame to avoid layout thrash.
*
* @param {MaybeComputedElementRef} target Element to measure
* @param {UseElementBoundingOptions} [options={}] Options
* @returns {UseElementBoundingReturn} Reactive bounds and a manual `update`
*
* @example
* const { width, height, top, left } = useElementBounding(el);
*
* @example
* // Batch rapid scroll/resize reads into one measurement per frame
* const bounds = useElementBounding(el, { updateTiming: 'next-frame' });
*
* @since 0.0.15
*/
export function useElementBounding(
target: MaybeComputedElementRef,
options: UseElementBoundingOptions = {},
): UseElementBoundingReturn {
const {
reset = true,
windowResize = true,
windowScroll = true,
immediate = true,
updateTiming = 'sync',
window = defaultWindow,
} = options;
const height = shallowRef(0);
const width = shallowRef(0);
const top = shallowRef(0);
const right = shallowRef(0);
const bottom = shallowRef(0);
const left = shallowRef(0);
const x = shallowRef(0);
const y = shallowRef(0);
function recalculate() {
const el = unrefElement(target);
if (!el) {
if (reset) {
height.value = 0;
width.value = 0;
top.value = 0;
right.value = 0;
bottom.value = 0;
left.value = 0;
x.value = 0;
y.value = 0;
}
return;
}
const rect = el.getBoundingClientRect();
height.value = rect.height;
width.value = rect.width;
top.value = rect.top;
right.value = rect.right;
bottom.value = rect.bottom;
left.value = rect.left;
x.value = rect.x;
y.value = rect.y;
}
// Pending animation frame id, so deferred reads coalesce and can be cancelled.
// `pending` is the source of truth for coalescing; `rafId` is only kept for
// cancellation. A separate flag avoids ordering bugs when the scheduler runs
// the callback synchronously (the assignment below would otherwise clobber the
// id the callback just cleared).
let pending = false;
let rafId: number | undefined;
function update() {
if (updateTiming === 'next-frame' && window) {
// Coalesce: only schedule one read per frame
if (pending)
return;
pending = true;
rafId = window.requestAnimationFrame(() => {
pending = false;
rafId = undefined;
recalculate();
});
return;
}
recalculate();
}
useResizeObserver(target, update);
watch(() => unrefElement(target), el => !el && update());
useMutationObserver(target, update, { attributeFilter: ['style', 'class'] });
if (windowScroll)
useEventListener('scroll', update, { capture: true, passive: true });
if (windowResize)
useEventListener('resize', update, { passive: true });
if (window && immediate)
update();
// Cancel any pending frame so we don't read a detached/disposed element
tryOnScopeDispose(() => {
if (pending && rafId !== undefined && window)
window.cancelAnimationFrame(rafId);
pending = false;
rafId = undefined;
});
return {
height,
width,
top,
right,
bottom,
left,
x,
y,
update,
};
}
@@ -1,183 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, isReadonly, ref } from 'vue';
import { useElementHover } from '.';
function dispatch(el: HTMLElement, type: 'mouseenter' | 'mouseleave') {
el.dispatchEvent(new Event(type));
}
describe(useElementHover, () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('is false initially', () => {
const el = document.createElement('div');
const scope = effectScope();
let isHovered: ReturnType<typeof useElementHover>;
scope.run(() => {
isHovered = useElementHover(ref(el));
});
expect(isHovered!.value).toBeFalsy();
scope.stop();
});
it('toggles on mouseenter / mouseleave', () => {
const el = document.createElement('div');
const scope = effectScope();
let isHovered: ReturnType<typeof useElementHover>;
scope.run(() => {
isHovered = useElementHover(ref(el));
});
dispatch(el, 'mouseenter');
expect(isHovered!.value).toBeTruthy();
dispatch(el, 'mouseleave');
expect(isHovered!.value).toBeFalsy();
scope.stop();
});
it('returns a writable shallow ref (not readonly)', () => {
const el = document.createElement('div');
const scope = effectScope();
let isHovered: ReturnType<typeof useElementHover>;
scope.run(() => {
isHovered = useElementHover(ref(el));
});
expect(isReadonly(isHovered!)).toBeFalsy();
scope.stop();
});
it('respects delayEnter', () => {
const el = document.createElement('div');
const scope = effectScope();
let isHovered: ReturnType<typeof useElementHover>;
scope.run(() => {
isHovered = useElementHover(ref(el), { delayEnter: 100 });
});
dispatch(el, 'mouseenter');
expect(isHovered!.value).toBeFalsy();
vi.advanceTimersByTime(99);
expect(isHovered!.value).toBeFalsy();
vi.advanceTimersByTime(1);
expect(isHovered!.value).toBeTruthy();
scope.stop();
});
it('respects delayLeave', () => {
const el = document.createElement('div');
const scope = effectScope();
let isHovered: ReturnType<typeof useElementHover>;
scope.run(() => {
isHovered = useElementHover(ref(el), { delayLeave: 200 });
});
dispatch(el, 'mouseenter');
expect(isHovered!.value).toBeTruthy();
dispatch(el, 'mouseleave');
expect(isHovered!.value).toBeTruthy();
vi.advanceTimersByTime(199);
expect(isHovered!.value).toBeTruthy();
vi.advanceTimersByTime(1);
expect(isHovered!.value).toBeFalsy();
scope.stop();
});
it('cancels a pending enter timer when leaving before the delay elapses', () => {
const el = document.createElement('div');
const scope = effectScope();
let isHovered: ReturnType<typeof useElementHover>;
scope.run(() => {
isHovered = useElementHover(ref(el), { delayEnter: 100 });
});
dispatch(el, 'mouseenter');
vi.advanceTimersByTime(50);
expect(isHovered!.value).toBeFalsy();
// Leaving cancels the pending enter; with no leave delay it settles to false.
dispatch(el, 'mouseleave');
vi.advanceTimersByTime(100);
expect(isHovered!.value).toBeFalsy();
scope.stop();
});
it('cancels a pending leave timer when re-entering before the delay elapses', () => {
const el = document.createElement('div');
const scope = effectScope();
let isHovered: ReturnType<typeof useElementHover>;
scope.run(() => {
isHovered = useElementHover(ref(el), { delayLeave: 200 });
});
dispatch(el, 'mouseenter');
expect(isHovered!.value).toBeTruthy();
dispatch(el, 'mouseleave');
vi.advanceTimersByTime(100);
// Re-enter before the leave delay finishes: stays hovered, no flip.
dispatch(el, 'mouseenter');
vi.advanceTimersByTime(200);
expect(isHovered!.value).toBeTruthy();
scope.stop();
});
it('stops listening once the scope is disposed', () => {
const el = document.createElement('div');
const scope = effectScope();
let isHovered: ReturnType<typeof useElementHover>;
scope.run(() => {
isHovered = useElementHover(ref(el));
});
scope.stop();
dispatch(el, 'mouseenter');
expect(isHovered!.value).toBeFalsy();
});
it('clears a pending timer on scope dispose (no late update)', () => {
const el = document.createElement('div');
const scope = effectScope();
let isHovered: ReturnType<typeof useElementHover>;
scope.run(() => {
isHovered = useElementHover(ref(el), { delayEnter: 100 });
});
dispatch(el, 'mouseenter');
scope.stop();
vi.advanceTimersByTime(200);
expect(isHovered!.value).toBeFalsy();
});
it('returns a static ref and registers no listeners when window is falsy (SSR)', () => {
// `defaultWindow` is captured at import time and cannot be stubbed via
// vi.stubGlobal, so we cast a falsy window through options to exercise the
// SSR early-return without touching the global. Note that passing literal
// `undefined` would resolve back to `defaultWindow` via the destructure
// default, hence the explicit null cast here.
const el = document.createElement('div');
const addSpy = vi.spyOn(el, 'addEventListener');
const scope = effectScope();
let isHovered: ReturnType<typeof useElementHover>;
scope.run(() => {
isHovered = useElementHover(ref(el), { window: null as unknown as Window });
});
expect(isHovered!.value).toBeFalsy();
expect(addSpy).not.toHaveBeenCalled();
dispatch(el, 'mouseenter');
expect(isHovered!.value).toBeFalsy();
scope.stop();
});
});
@@ -1,91 +0,0 @@
import { computed, shallowRef } from 'vue';
import type { ShallowRef } from 'vue';
import type { ConfigurableWindow } from '@/types';
import { defaultWindow } from '@/types';
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
import { unrefElement } from '@/composables/component/unrefElement';
import { useEventListener } from '@/composables/browser/useEventListener';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
export interface UseElementHoverOptions extends ConfigurableWindow {
/**
* Delay in milliseconds before flipping the state to hovered on `mouseenter`.
*
* @default 0
*/
delayEnter?: number;
/**
* Delay in milliseconds before flipping the state to not-hovered on `mouseleave`.
*
* @default 0
*/
delayLeave?: number;
}
export type UseElementHoverReturn = ShallowRef<boolean>;
/**
* @name useElementHover
* @category Browser
* @description Reactive hover state of an element, driven by `mouseenter` /
* `mouseleave`. Supports independent enter/leave delays to debounce flicker.
*
* @param {MaybeComputedElementRef} target Element to track (ref, getter, or component instance)
* @param {UseElementHoverOptions} [options={}] Options (`delayEnter`, `delayLeave`, `window`)
* @returns {UseElementHoverReturn} Reactive hover state ref
*
* @example
* const el = useTemplateRef('el');
* const isHovered = useElementHover(el);
*
* @example
* const isHovered = useElementHover(el, { delayEnter: 100, delayLeave: 200 });
*
* @since 0.0.15
*/
export function useElementHover(
target: MaybeComputedElementRef,
options: UseElementHoverOptions = {},
): UseElementHoverReturn {
const {
delayEnter = 0,
delayLeave = 0,
window = defaultWindow,
} = options;
const isHovered = shallowRef(false);
let timer: ReturnType<typeof setTimeout> | undefined;
const clear = (): void => {
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
};
const toggle = (entering: boolean): void => {
const delay = entering ? delayEnter : delayLeave;
clear();
if (delay)
timer = setTimeout(() => { isHovered.value = entering; }, delay);
else
isHovered.value = entering;
};
// SSR / no DOM: return a static, never-updating ref.
if (!window)
return isHovered;
const targetElement = computed(() => unrefElement(target) as HTMLElement | undefined | null);
useEventListener(targetElement, 'mouseenter', () => toggle(true), { passive: true });
useEventListener(targetElement, 'mouseleave', () => toggle(false), { passive: true });
tryOnScopeDispose(clear);
return isHovered;
}
@@ -1,229 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, ref } from 'vue';
import { useElementSize } from '.';
interface StubInstance {
cb: ResizeObserverCallback;
observe: ReturnType<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn>;
unobserve: ReturnType<typeof vi.fn>;
}
let instances: StubInstance[] = [];
class StubResizeObserver {
observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
cb: ResizeObserverCallback;
constructor(cb: ResizeObserverCallback) {
this.cb = cb;
instances.push(this);
}
}
function fire(width: number, height: number, fields: Partial<ResizeObserverEntry> = {}) {
instances[0]!.cb([
{
contentBoxSize: [{ inlineSize: width, blockSize: height }],
contentRect: { width, height },
...fields,
} as unknown as ResizeObserverEntry,
], {} as ResizeObserver);
}
describe(useElementSize, () => {
beforeEach(() => {
instances = [];
vi.stubGlobal('ResizeObserver', StubResizeObserver);
});
afterEach(() => vi.unstubAllGlobals());
it('uses the initial size when the target resolves to no element', () => {
const scope = effectScope();
let size: ReturnType<typeof useElementSize>;
scope.run(() => {
size = useElementSize(ref(undefined), { width: 5, height: 7 });
});
expect(size!.width.value).toBe(5);
expect(size!.height.value).toBe(7);
scope.stop();
});
it('measures synchronously on mount via offset size', () => {
const el = document.createElement('div');
Object.defineProperty(el, 'offsetWidth', { value: 80, configurable: true });
Object.defineProperty(el, 'offsetHeight', { value: 60, configurable: true });
const scope = effectScope();
let size: ReturnType<typeof useElementSize>;
scope.run(() => {
size = useElementSize(ref(el), { width: 5, height: 7 });
});
// tryOnMounted runs synchronously outside a component, overwriting the initial size.
expect(size!.width.value).toBe(80);
expect(size!.height.value).toBe(60);
scope.stop();
});
it('reports size from contentBoxSize', () => {
const el = document.createElement('div');
const scope = effectScope();
let size: ReturnType<typeof useElementSize>;
scope.run(() => {
size = useElementSize(ref(el));
});
instances[0]!.cb([
{ contentBoxSize: [{ inlineSize: 100, blockSize: 50 }], contentRect: { width: 0, height: 0 } } as unknown as ResizeObserverEntry,
], {} as ResizeObserver);
expect(size!.width.value).toBe(100);
expect(size!.height.value).toBe(50);
scope.stop();
});
it('falls back to contentRect when box sizes are missing', () => {
const el = document.createElement('div');
const scope = effectScope();
let size: ReturnType<typeof useElementSize>;
scope.run(() => {
size = useElementSize(ref(el));
});
instances[0]!.cb([
{ contentBoxSize: undefined, contentRect: { width: 30, height: 40 } } as unknown as ResizeObserverEntry,
], {} as ResizeObserver);
expect(size!.width.value).toBe(30);
expect(size!.height.value).toBe(40);
scope.stop();
});
it('normalises a single (non-array) ResizeObserverSize object', () => {
const el = document.createElement('div');
const scope = effectScope();
let size: ReturnType<typeof useElementSize>;
scope.run(() => {
size = useElementSize(ref(el));
});
// Older Firefox reports box sizes as a single object rather than an array.
instances[0]!.cb([
{ contentBoxSize: { inlineSize: 12, blockSize: 34 }, contentRect: { width: 0, height: 0 } } as unknown as ResizeObserverEntry,
], {} as ResizeObserver);
expect(size!.width.value).toBe(12);
expect(size!.height.value).toBe(34);
scope.stop();
});
it('sums multiple box fragments in a single pass', () => {
const el = document.createElement('div');
const scope = effectScope();
let size: ReturnType<typeof useElementSize>;
scope.run(() => {
size = useElementSize(ref(el));
});
instances[0]!.cb([
{
contentBoxSize: [
{ inlineSize: 10, blockSize: 5 },
{ inlineSize: 20, blockSize: 7 },
],
contentRect: { width: 0, height: 0 },
} as unknown as ResizeObserverEntry,
], {} as ResizeObserver);
expect(size!.width.value).toBe(30);
expect(size!.height.value).toBe(12);
scope.stop();
});
it('reads borderBoxSize when box is "border-box"', () => {
const el = document.createElement('div');
const scope = effectScope();
let size: ReturnType<typeof useElementSize>;
scope.run(() => {
size = useElementSize(ref(el), { width: 0, height: 0 }, { box: 'border-box' });
});
instances[0]!.cb([
{
borderBoxSize: [{ inlineSize: 200, blockSize: 120 }],
contentBoxSize: [{ inlineSize: 1, blockSize: 1 }],
contentRect: { width: 0, height: 0 },
} as unknown as ResizeObserverEntry,
], {} as ResizeObserver);
expect(size!.width.value).toBe(200);
expect(size!.height.value).toBe(120);
scope.stop();
});
it('measures SVG elements via getBoundingClientRect', () => {
const el = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
el.getBoundingClientRect = () => ({ width: 64, height: 48 }) as DOMRect;
const scope = effectScope();
let size: ReturnType<typeof useElementSize>;
scope.run(() => {
size = useElementSize(ref(el), { width: 0, height: 0 }, { window: globalThis as unknown as Window });
});
// Even though the entry advertises a different box size, the SVG path wins.
instances[0]!.cb([
{ contentBoxSize: [{ inlineSize: 999, blockSize: 999 }], contentRect: { width: 999, height: 999 } } as unknown as ResizeObserverEntry,
], {} as ResizeObserver);
expect(size!.width.value).toBe(64);
expect(size!.height.value).toBe(48);
scope.stop();
});
it('resets to 0 when the element detaches', async () => {
const el = ref<HTMLElement | undefined>(document.createElement('div'));
const scope = effectScope();
let size: ReturnType<typeof useElementSize>;
scope.run(() => {
size = useElementSize(el, { width: 5, height: 7 });
});
fire(100, 50);
expect(size!.width.value).toBe(100);
el.value = undefined;
await nextTick();
expect(size!.width.value).toBe(0);
expect(size!.height.value).toBe(0);
scope.stop();
});
it('stop() disconnects the observer and the detach watcher', async () => {
const el = ref<HTMLElement | undefined>(document.createElement('div'));
const scope = effectScope();
let size: ReturnType<typeof useElementSize>;
scope.run(() => {
size = useElementSize(el, { width: 0, height: 0 });
});
await nextTick();
expect(instances[0]!.disconnect).not.toHaveBeenCalled();
fire(100, 50);
expect(size!.width.value).toBe(100);
size!.stop();
// The observer is torn down so it stops delivering callbacks in a real browser.
expect(instances[0]!.disconnect).toHaveBeenCalled();
// The detach watcher is also stopped: clearing the target no longer resets the size to 0.
el.value = undefined;
await nextTick();
expect(size!.width.value).toBe(100);
expect(size!.height.value).toBe(50);
scope.stop();
});
});
@@ -1,121 +0,0 @@
import { computed, shallowRef, watch } from 'vue';
import type { ShallowRef } from 'vue';
import { toArray } from '@robonen/stdlib';
import type { ConfigurableWindow } from '@/types';
import { defaultWindow } from '@/types';
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
import { unrefElement } from '@/composables/component/unrefElement';
import { useResizeObserver } from '@/composables/browser/useResizeObserver';
import type { UseResizeObserverOptions } from '@/composables/browser/useResizeObserver';
import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted';
export interface ElementSize {
width: number;
height: number;
}
export interface UseElementSizeOptions extends UseResizeObserverOptions, ConfigurableWindow {}
export interface UseElementSizeReturn {
width: ShallowRef<number>;
height: ShallowRef<number>;
stop: () => void;
}
/**
* @name useElementSize
* @category Browser
* @description Reactive size of an element, backed by `ResizeObserver`.
* Measures synchronously on mount, handles SVG elements via `getBoundingClientRect`,
* and sums multiple box fragments (e.g. multi-column layouts).
*
* @param {MaybeComputedElementRef} target Element to measure (ref, getter, or component instance)
* @param {ElementSize} [initialSize={ width: 0, height: 0 }] Initial size, restored when the element detaches
* @param {UseElementSizeOptions} [options={}] Options forwarded to `ResizeObserver` (`box`, `window`)
* @returns {UseElementSizeReturn} Reactive `width`, `height`, and a `stop` handle
*
* @example
* const el = useTemplateRef('el');
* const { width, height } = useElementSize(el);
*
* @example
* const { width, height, stop } = useElementSize(el, { width: 100, height: 100 }, { box: 'border-box' });
*
* @since 0.0.15
*/
export function useElementSize(
target: MaybeComputedElementRef,
initialSize: ElementSize = { width: 0, height: 0 },
options: UseElementSizeOptions = {},
): UseElementSizeReturn {
const { window = defaultWindow, box = 'content-box' } = options;
const width = shallowRef(initialSize.width);
const height = shallowRef(initialSize.height);
const isSVG = computed(() => unrefElement(target)?.namespaceURI?.includes('svg'));
const { stop: stopObserver } = useResizeObserver(target, ([entry]) => {
if (!entry)
return;
// SVG elements report unreliable box sizes in some browsers; measure the layout box instead.
if (window && isSVG.value) {
const el = unrefElement(target);
if (el) {
const rect = el.getBoundingClientRect();
width.value = rect.width;
height.value = rect.height;
}
return;
}
const boxSize = box === 'border-box'
? entry.borderBoxSize
: box === 'content-box'
? entry.contentBoxSize
: entry.devicePixelContentBoxSize;
if (boxSize) {
// Normalise the cross-browser `ResizeObserverSize | ReadonlyArray<ResizeObserverSize>` shape
// and sum fragments (e.g. multi-column layouts) in a single pass.
let nextWidth = 0;
let nextHeight = 0;
for (const size of toArray(boxSize as ResizeObserverSize | ResizeObserverSize[])) {
nextWidth += size.inlineSize;
nextHeight += size.blockSize;
}
width.value = nextWidth;
height.value = nextHeight;
}
else {
width.value = entry.contentRect.width;
height.value = entry.contentRect.height;
}
}, options);
// Provide a measurement immediately on mount, before the first observer callback fires.
tryOnMounted(() => {
const el = unrefElement(target);
if (el) {
width.value = 'offsetWidth' in el ? (el as HTMLElement).offsetWidth : initialSize.width;
height.value = 'offsetHeight' in el ? (el as HTMLElement).offsetHeight : initialSize.height;
}
});
// Reset to the initial size when the element is attached/detached.
const stopWatch = watch(
() => unrefElement(target),
(el) => {
width.value = el ? initialSize.width : 0;
height.value = el ? initialSize.height : 0;
},
);
const stop = (): void => {
stopObserver();
stopWatch();
};
return { width, height, stop };
}
@@ -1,145 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, isReadonly, ref } from 'vue';
import type { UseElementVisibilityReturn } from '.';
import { useElementVisibility } from '.';
let instances: StubIntersectionObserver[] = [];
let lastInit: IntersectionObserverInit | undefined;
class StubIntersectionObserver {
observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
takeRecords = vi.fn();
cb: IntersectionObserverCallback;
init?: IntersectionObserverInit;
constructor(cb: IntersectionObserverCallback, init?: IntersectionObserverInit) {
this.cb = cb;
this.init = init;
lastInit = init;
instances.push(this);
}
}
describe(useElementVisibility, () => {
beforeEach(() => {
instances = [];
lastInit = undefined;
vi.stubGlobal('IntersectionObserver', StubIntersectionObserver);
});
afterEach(() => vi.unstubAllGlobals());
it('is false initially and updates on intersection', () => {
const el = document.createElement('div');
const scope = effectScope();
let isVisible: UseElementVisibilityReturn<false>;
scope.run(() => {
isVisible = useElementVisibility(ref(el));
});
expect(isVisible!.value).toBeFalsy();
instances[0]!.cb([{ isIntersecting: true, time: 1 } as IntersectionObserverEntry], {} as IntersectionObserver);
expect(isVisible!.value).toBeTruthy();
instances[0]!.cb([{ isIntersecting: false, time: 2 } as IntersectionObserverEntry], {} as IntersectionObserver);
expect(isVisible!.value).toBeFalsy();
scope.stop();
});
it('uses the most recent entry by time', () => {
const el = document.createElement('div');
const scope = effectScope();
let isVisible: UseElementVisibilityReturn<false>;
scope.run(() => {
isVisible = useElementVisibility(ref(el));
});
instances[0]!.cb([
{ isIntersecting: false, time: 5 } as IntersectionObserverEntry,
{ isIntersecting: true, time: 10 } as IntersectionObserverEntry,
], {} as IntersectionObserver);
expect(isVisible!.value).toBeTruthy();
scope.stop();
});
it('respects initialValue', () => {
const el = document.createElement('div');
const scope = effectScope();
let isVisible: UseElementVisibilityReturn<false>;
scope.run(() => {
isVisible = useElementVisibility(ref(el), { initialValue: true });
});
expect(isVisible!.value).toBeTruthy();
scope.stop();
});
it('returns a writable shallow ref (not readonly) by default', () => {
const el = document.createElement('div');
const scope = effectScope();
let isVisible: UseElementVisibilityReturn<false>;
scope.run(() => {
isVisible = useElementVisibility(ref(el));
});
expect(isReadonly(isVisible!)).toBeFalsy();
scope.stop();
});
it('forwards rootMargin and threshold to the observer', () => {
const el = document.createElement('div');
const scope = effectScope();
scope.run(() => useElementVisibility(ref(el), { rootMargin: '10px', threshold: [0, 0.5, 1] }));
expect(lastInit?.rootMargin).toBe('10px');
expect(lastInit?.threshold).toEqual([0, 0.5, 1]);
scope.stop();
});
it('stops observing after first visibility when once is true', () => {
const el = document.createElement('div');
const scope = effectScope();
let isVisible: UseElementVisibilityReturn<false>;
scope.run(() => {
isVisible = useElementVisibility(ref(el), { once: true });
});
const observer = instances[0]!;
// Not visible yet: should not disconnect.
observer.cb([{ isIntersecting: false, time: 1 } as IntersectionObserverEntry], {} as IntersectionObserver);
expect(observer.disconnect).not.toHaveBeenCalled();
expect(isVisible!.value).toBeFalsy();
// Becomes visible: stop() should disconnect the observer.
observer.cb([{ isIntersecting: true, time: 2 } as IntersectionObserverEntry], {} as IntersectionObserver);
expect(isVisible!.value).toBeTruthy();
expect(observer.disconnect).toHaveBeenCalled();
scope.stop();
});
it('exposes observer controls when controls is true', () => {
const el = document.createElement('div');
const scope = effectScope();
let result: UseElementVisibilityReturn<true>;
scope.run(() => {
result = useElementVisibility(ref(el), { controls: true });
});
expect(result!).toHaveProperty('isVisible');
expect(result!).toHaveProperty('stop');
expect(result!).toHaveProperty('pause');
expect(result!).toHaveProperty('resume');
expect(result!).toHaveProperty('isSupported');
expect(result!).toHaveProperty('isActive');
expect(result!.isVisible.value).toBeFalsy();
instances[0]!.cb([{ isIntersecting: true, time: 1 } as IntersectionObserverEntry], {} as IntersectionObserver);
expect(result!.isVisible.value).toBeTruthy();
result!.stop();
expect(instances[0]!.disconnect).toHaveBeenCalled();
scope.stop();
});
});
@@ -1,108 +0,0 @@
import { shallowRef } from 'vue';
import type { ShallowRef } from 'vue';
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
import { useIntersectionObserver } from '@/composables/browser/useIntersectionObserver';
import type { UseIntersectionObserverOptions, UseIntersectionObserverReturn } from '@/composables/browser/useIntersectionObserver';
export interface UseElementVisibilityOptions<Controls extends boolean = false> extends UseIntersectionObserverOptions {
/**
* The initial visibility state, used before the observer reports its first entry.
*
* @default false
*/
initialValue?: boolean;
/**
* Stop observing as soon as the element becomes visible for the first time.
*
* @default false
*/
once?: boolean;
/**
* Expose the underlying observer controls (`pause`, `resume`, `stop`, ...)
* alongside the visibility ref instead of returning the ref directly.
*
* @default false
*/
controls?: Controls;
}
export interface UseElementVisibilityReturnWithControls extends UseIntersectionObserverReturn {
/**
* Whether the element is currently visible within the root/viewport.
*/
isVisible: ShallowRef<boolean>;
}
export type UseElementVisibilityReturn<Controls extends boolean = false>
= Controls extends true
? UseElementVisibilityReturnWithControls
: ShallowRef<boolean>;
/**
* @name useElementVisibility
* @category Browser
* @description Track whether an element is visible within the viewport (or a
* custom scroll root), backed by `IntersectionObserver`.
*
* @param {MaybeComputedElementRef} target Element to track
* @param {UseElementVisibilityOptions} [options={}] Options
* @returns {UseElementVisibilityReturn} Visibility ref, or `{ isVisible, ...controls }` when `controls` is `true`
*
* @example
* const isVisible = useElementVisibility(el);
*
* @example
* const { isVisible, stop } = useElementVisibility(el, { controls: true, once: true });
*
* @since 0.0.15
*/
export function useElementVisibility(
target: MaybeComputedElementRef,
options?: UseElementVisibilityOptions<false>,
): UseElementVisibilityReturn<false>;
export function useElementVisibility(
target: MaybeComputedElementRef,
options: UseElementVisibilityOptions<true>,
): UseElementVisibilityReturn<true>;
export function useElementVisibility(
target: MaybeComputedElementRef,
options: UseElementVisibilityOptions<boolean> = {},
): UseElementVisibilityReturn<boolean> {
const {
initialValue = false,
once = false,
controls = false,
...observerOptions
} = options;
const isVisible = shallowRef(initialValue);
const observer = useIntersectionObserver(target, (entries) => {
// Use the most recent entry to reflect the latest state.
let latest = isVisible.value;
let latestTime = 0;
for (const entry of entries) {
if (entry.time >= latestTime) {
latestTime = entry.time;
latest = entry.isIntersecting;
}
}
isVisible.value = latest;
if (once && latest)
observer.stop();
}, observerOptions);
if (controls) {
return {
...observer,
isVisible,
};
}
return isVisible;
}
@@ -1,59 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useEscapeKey } from '.';
function dispatchEscape() {
globalThis.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
}
describe(useEscapeKey, () => {
afterEach(() => {
// Ensure no lingering subscribers between tests
});
it('fires handler on Escape', () => {
const h = vi.fn();
const stop = useEscapeKey(h);
dispatchEscape();
expect(h).toHaveBeenCalledTimes(1);
stop();
});
it('ignores non-Escape keys', () => {
const h = vi.fn();
const stop = useEscapeKey(h);
globalThis.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
expect(h).not.toHaveBeenCalled();
stop();
});
it('only topmost handler fires when multiple are stacked', () => {
const bottom = vi.fn();
const top = vi.fn();
const stopBottom = useEscapeKey(bottom);
const stopTop = useEscapeKey(top);
dispatchEscape();
expect(top).toHaveBeenCalledTimes(1);
expect(bottom).not.toHaveBeenCalled();
stopTop();
dispatchEscape();
expect(bottom).toHaveBeenCalledTimes(1);
stopBottom();
});
it('stop handle unsubscribes the listener', () => {
const h = vi.fn();
const stop = useEscapeKey(h);
stop();
dispatchEscape();
expect(h).not.toHaveBeenCalled();
});
});
@@ -1,60 +0,0 @@
import type { VoidFunction } from '@robonen/stdlib';
import { noop } from '@robonen/stdlib';
import { useEventListener } from '@/composables/browser/useEventListener';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
import { defaultWindow } from '@/types';
type EscapeListener = (event: KeyboardEvent) => void;
// Module-scoped stack: only the topmost non-paused layer handles Escape so that
// nested dismissables behave correctly (top-most dialog closes first).
const stack: EscapeListener[] = [];
let installed = false;
let cleanup: VoidFunction = noop;
function install() {
if (installed || !defaultWindow) return;
installed = true;
cleanup = useEventListener(defaultWindow, 'keydown', (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
const top = stack.at(-1);
top?.(event);
}, { capture: true });
}
function uninstall() {
if (!installed || stack.length > 0) return;
installed = false;
cleanup();
cleanup = noop;
}
/**
* @name useEscapeKey
* @category Browser
* @description Register a callback for the topmost Escape keydown. Uses an internal
* stack so that nested layers (e.g. nested Dialogs) dismiss in the correct order —
* only the most recently-registered listener fires for a given keydown.
*
* @param {(event: KeyboardEvent) => void} handler Callback invoked on the topmost Escape
* @returns {VoidFunction} Stop handle that removes the subscription
*
* @since 0.0.14
*/
export function useEscapeKey(handler: EscapeListener): VoidFunction {
if (!defaultWindow) return noop;
install();
stack.push(handler);
const stop = () => {
const i = stack.lastIndexOf(handler);
if (i !== -1) stack.splice(i, 1);
uninstall();
};
tryOnScopeDispose(stop);
return stop;
}
@@ -2,7 +2,7 @@ import { shallowRef } from 'vue';
import type { ComputedRef, ShallowRef } from 'vue';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { useSupported } from '@/composables/browser/useSupported';
import { useSupported } from '@/composables/utilities/useSupported';
export interface EyeDropperOpenOptions {
/**
@@ -0,0 +1,314 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, ref } from 'vue';
import type { FileSystemFileHandle, UseFileSystemAccessReturn } from '.';
import { useFileSystemAccess } from '.';
interface WritableSpy {
write: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
}
function makeFile(contents = 'hello world', name = 'demo.txt', type = 'text/plain'): File {
const file = {
name,
type,
size: contents.length,
lastModified: 1234,
text: vi.fn(async () => contents),
arrayBuffer: vi.fn(async () => new ArrayBuffer(contents.length)),
};
return file as unknown as File;
}
function makeHandle(file: File): { handle: FileSystemFileHandle; writable: WritableSpy } {
const writable: WritableSpy = {
write: vi.fn(async () => {}),
close: vi.fn(async () => {}),
};
const handle = {
getFile: vi.fn(async () => file),
createWritable: vi.fn(async () => writable),
} as unknown as FileSystemFileHandle;
return { handle, writable };
}
function stubWindow(handle?: FileSystemFileHandle) {
const showOpenFilePicker = vi.fn(async () => (handle ? [handle] : []));
const showSaveFilePicker = vi.fn(async () => handle);
const window = {
showOpenFilePicker,
showSaveFilePicker,
} as unknown as Window;
return { window, showOpenFilePicker, showSaveFilePicker };
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe(useFileSystemAccess, () => {
it('reports support when the picker APIs exist', () => {
const { window } = stubWindow();
const scope = effectScope();
let fsa: UseFileSystemAccessReturn;
scope.run(() => {
fsa = useFileSystemAccess({ window });
});
expect(fsa!.isSupported.value).toBeTruthy();
scope.stop();
});
it('is not supported without the picker APIs', () => {
const window = {} as unknown as Window;
const scope = effectScope();
let fsa: UseFileSystemAccessReturn;
scope.run(() => {
fsa = useFileSystemAccess({ window });
});
expect(fsa!.isSupported.value).toBeFalsy();
scope.stop();
});
it('is not supported and is a no-op under SSR (no window)', async () => {
const scope = effectScope();
let fsa: UseFileSystemAccessReturn;
scope.run(() => {
fsa = useFileSystemAccess({ window: undefined });
});
expect(fsa!.isSupported.value).toBeFalsy();
await expect(fsa!.open()).resolves.toBeUndefined();
await expect(fsa!.save()).resolves.toBeUndefined();
expect(fsa!.fileName.value).toBe('');
expect(fsa!.fileSize.value).toBe(0);
expect(fsa!.data.value).toBeUndefined();
scope.stop();
});
it('opens a file and reads it as text by default', async () => {
const file = makeFile('content');
const { handle } = makeHandle(file);
const { window, showOpenFilePicker } = stubWindow(handle);
const scope = effectScope();
let fsa: UseFileSystemAccessReturn<string>;
scope.run(() => {
fsa = useFileSystemAccess({ window, dataType: 'Text' });
});
await fsa!.open();
expect(showOpenFilePicker).toHaveBeenCalledOnce();
expect(fsa!.data.value).toBe('content');
expect(fsa!.file.value).toBe(file);
expect(fsa!.fileName.value).toBe('demo.txt');
expect(fsa!.fileMIME.value).toBe('text/plain');
expect(fsa!.fileSize.value).toBe(7);
expect(fsa!.fileLastModified.value).toBe(1234);
scope.stop();
});
it('reads as ArrayBuffer when dataType is ArrayBuffer', async () => {
const file = makeFile('abc');
const { handle } = makeHandle(file);
const { window } = stubWindow(handle);
const scope = effectScope();
let fsa: UseFileSystemAccessReturn<ArrayBuffer>;
scope.run(() => {
fsa = useFileSystemAccess({ window, dataType: 'ArrayBuffer' });
});
await fsa!.open();
expect(fsa!.data.value).toBeInstanceOf(ArrayBuffer);
expect(file.arrayBuffer).toHaveBeenCalled();
scope.stop();
});
it('exposes the File itself when dataType is Blob', async () => {
const file = makeFile('abc');
const { handle } = makeHandle(file);
const { window } = stubWindow(handle);
const scope = effectScope();
let fsa: UseFileSystemAccessReturn<Blob>;
scope.run(() => {
fsa = useFileSystemAccess({ window, dataType: 'Blob' });
});
await fsa!.open();
expect(fsa!.data.value).toBe(file);
scope.stop();
});
it('passes types and excludeAcceptAllOption to the open picker', async () => {
const file = makeFile();
const { handle } = makeHandle(file);
const { window, showOpenFilePicker } = stubWindow(handle);
const types = [{ description: 'text', accept: { 'text/plain': ['.txt'] } }];
const scope = effectScope();
let fsa: UseFileSystemAccessReturn;
scope.run(() => {
fsa = useFileSystemAccess({ window, types, excludeAcceptAllOption: true });
});
await fsa!.open();
expect(showOpenFilePicker).toHaveBeenCalledWith({ types, excludeAcceptAllOption: true });
scope.stop();
});
it('lets per-call open options override the defaults', async () => {
const file = makeFile();
const { handle } = makeHandle(file);
const { window, showOpenFilePicker } = stubWindow(handle);
const scope = effectScope();
let fsa: UseFileSystemAccessReturn;
scope.run(() => {
fsa = useFileSystemAccess({ window, excludeAcceptAllOption: false });
});
await fsa!.open({ excludeAcceptAllOption: true });
expect(showOpenFilePicker).toHaveBeenCalledWith({ types: undefined, excludeAcceptAllOption: true });
scope.stop();
});
it('creates a new empty handle and clears prior data', async () => {
const file = makeFile('');
const { handle } = makeHandle(file);
const { window, showSaveFilePicker } = stubWindow(handle);
const scope = effectScope();
let fsa: UseFileSystemAccessReturn;
scope.run(() => {
fsa = useFileSystemAccess({ window });
});
await fsa!.create({ suggestedName: 'new.txt' });
expect(showSaveFilePicker).toHaveBeenCalledWith({ types: undefined, excludeAcceptAllOption: undefined, suggestedName: 'new.txt' });
expect(fsa!.file.value).toBe(file);
scope.stop();
});
it('save writes current data to the existing handle', async () => {
const file = makeFile('original');
const { handle, writable } = makeHandle(file);
const { window } = stubWindow(handle);
const scope = effectScope();
let fsa: UseFileSystemAccessReturn<string>;
scope.run(() => {
fsa = useFileSystemAccess({ window, dataType: 'Text' });
});
await fsa!.open();
fsa!.data.value = 'edited';
await fsa!.save();
expect(writable.write).toHaveBeenCalledWith('edited');
expect(writable.close).toHaveBeenCalledOnce();
scope.stop();
});
it('save falls back to saveAs when there is no handle', async () => {
const file = makeFile('');
const { handle, writable } = makeHandle(file);
const { window, showSaveFilePicker, showOpenFilePicker } = stubWindow(handle);
const scope = effectScope();
let fsa: UseFileSystemAccessReturn<string>;
scope.run(() => {
fsa = useFileSystemAccess({ window, dataType: 'Text' });
});
fsa!.data.value = 'fresh';
await fsa!.save();
expect(showOpenFilePicker).not.toHaveBeenCalled();
expect(showSaveFilePicker).toHaveBeenCalledOnce();
expect(writable.write).toHaveBeenCalledWith('fresh');
scope.stop();
});
it('saveAs requests a new handle and writes data', async () => {
const file = makeFile('');
const { handle, writable } = makeHandle(file);
const { window, showSaveFilePicker } = stubWindow(handle);
const scope = effectScope();
let fsa: UseFileSystemAccessReturn<string>;
scope.run(() => {
fsa = useFileSystemAccess({ window, dataType: 'Text' });
});
fsa!.data.value = 'payload';
await fsa!.saveAs({ suggestedName: 'out.txt' });
expect(showSaveFilePicker).toHaveBeenCalledWith({ types: undefined, excludeAcceptAllOption: undefined, suggestedName: 'out.txt' });
expect(writable.write).toHaveBeenCalledWith('payload');
scope.stop();
});
it('does not write when there is no data', async () => {
const file = makeFile('');
const { handle, writable } = makeHandle(file);
const { window } = stubWindow(handle);
const scope = effectScope();
let fsa: UseFileSystemAccessReturn<string>;
scope.run(() => {
fsa = useFileSystemAccess({ window, dataType: 'Text' });
});
await fsa!.saveAs();
expect(writable.write).not.toHaveBeenCalled();
scope.stop();
});
it('updateData re-reads the current file', async () => {
const file = makeFile('v1');
const { handle } = makeHandle(file);
const { window } = stubWindow(handle);
const scope = effectScope();
let fsa: UseFileSystemAccessReturn<string>;
scope.run(() => {
fsa = useFileSystemAccess({ window, dataType: 'Text' });
});
await fsa!.open();
expect(fsa!.data.value).toBe('v1');
(file.text as ReturnType<typeof vi.fn>).mockResolvedValueOnce('v2');
await fsa!.updateData();
expect(fsa!.data.value).toBe('v2');
scope.stop();
});
it('re-reads data when a reactive dataType changes', async () => {
const file = makeFile('reactive');
const { handle } = makeHandle(file);
const { window } = stubWindow(handle);
const dataType = ref<'Text' | 'Blob'>('Text');
const scope = effectScope();
let fsa: UseFileSystemAccessReturn;
scope.run(() => {
fsa = useFileSystemAccess({ window, dataType });
});
await fsa!.open();
expect(fsa!.data.value).toBe('reactive');
dataType.value = 'Blob';
await nextTick();
await Promise.resolve();
expect(fsa!.data.value).toBe(file);
scope.stop();
});
it('routes picker errors to onError instead of throwing', async () => {
const abort = new DOMException('cancelled', 'AbortError');
const window = {
showOpenFilePicker: vi.fn(async () => { throw abort; }),
showSaveFilePicker: vi.fn(async () => { throw abort; }),
} as unknown as Window;
const onError = vi.fn();
const scope = effectScope();
let fsa: UseFileSystemAccessReturn;
scope.run(() => {
fsa = useFileSystemAccess({ window, onError });
});
await expect(fsa!.open()).resolves.toBeUndefined();
expect(onError).toHaveBeenCalledWith(abort);
scope.stop();
});
});
@@ -0,0 +1,332 @@
import { computed, shallowRef, toValue, watch } from 'vue';
import type { ComputedRef, MaybeRefOrGetter, ShallowRef } from 'vue';
import { noop } from '@robonen/stdlib';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { useSupported } from '@/composables/utilities/useSupported';
/**
* `window.showOpenFilePicker` parameters.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Window/showOpenFilePicker#parameters
*/
export interface FileSystemAccessShowOpenFileOptions {
multiple?: boolean;
types?: Array<{
description?: string;
accept: Record<string, string[]>;
}>;
excludeAcceptAllOption?: boolean;
}
/**
* `window.showSaveFilePicker` parameters.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Window/showSaveFilePicker#parameters
*/
export interface FileSystemAccessShowSaveFileOptions {
suggestedName?: string;
types?: Array<{
description?: string;
accept: Record<string, string[]>;
}>;
excludeAcceptAllOption?: boolean;
}
/**
* @see https://developer.mozilla.org/en-US/docs/Web/API/FileSystemWritableFileStream/write
*/
export interface FileSystemWritableFileStreamWrite {
(data: string | BufferSource | Blob): Promise<void>;
(options: { type: 'write'; position: number; data: string | BufferSource | Blob }): Promise<void>;
(options: { type: 'seek'; position: number }): Promise<void>;
(options: { type: 'truncate'; size: number }): Promise<void>;
}
/**
* @see https://developer.mozilla.org/en-US/docs/Web/API/FileSystemWritableFileStream
*/
export interface FileSystemWritableFileStream extends WritableStream {
write: FileSystemWritableFileStreamWrite;
seek: (position: number) => Promise<void>;
truncate: (size: number) => Promise<void>;
}
/**
* @see https://developer.mozilla.org/en-US/docs/Web/API/FileSystemFileHandle
*/
export interface FileSystemFileHandle {
getFile: () => Promise<File>;
createWritable: () => Promise<FileSystemWritableFileStream>;
}
/**
* A `window` augmented with the File System Access API entry points.
*/
export type FileSystemAccessWindow
= Window & {
showSaveFilePicker: (options: FileSystemAccessShowSaveFileOptions) => Promise<FileSystemFileHandle>;
showOpenFilePicker: (options: FileSystemAccessShowOpenFileOptions) => Promise<FileSystemFileHandle[]>;
};
/**
* The supported file data types.
*/
export type UseFileSystemAccessDataType = 'Text' | 'ArrayBuffer' | 'Blob';
/**
* Picker options shared between open/create/save operations.
*/
export type UseFileSystemAccessCommonOptions
= Pick<FileSystemAccessShowOpenFileOptions, 'types' | 'excludeAcceptAllOption'>;
/**
* Picker options accepted by save-style operations.
*/
export type UseFileSystemAccessShowSaveFileOptions
= Pick<FileSystemAccessShowSaveFileOptions, 'suggestedName'> & UseFileSystemAccessCommonOptions;
export type UseFileSystemAccessOptions
= ConfigurableWindow & UseFileSystemAccessCommonOptions & {
/**
* How the file contents are read into `data`.
*
* @default 'Text'
*/
dataType?: MaybeRefOrGetter<UseFileSystemAccessDataType>;
/**
* Called when a picker or file operation fails.
*
* User-cancelled pickers reject with an `AbortError`; route them here to
* keep them out of the global unhandled-rejection channel.
*
* @default noop
*/
onError?: (error: unknown) => void;
};
export interface UseFileSystemAccessReturn<T = string | ArrayBuffer | Blob> {
/**
* Whether the File System Access API is available.
*/
isSupported: ComputedRef<boolean>;
/**
* The current file contents, read according to `dataType`.
*/
data: ShallowRef<T | undefined>;
/**
* The currently bound `File`, or `undefined` when no file is open.
*/
file: ShallowRef<File | undefined>;
/**
* The current file name (empty string when no file is open).
*/
fileName: ComputedRef<string>;
/**
* The current file MIME type (empty string when no file is open).
*/
fileMIME: ComputedRef<string>;
/**
* The current file size in bytes (`0` when no file is open).
*/
fileSize: ComputedRef<number>;
/**
* The current file's last-modified timestamp (`0` when no file is open).
*/
fileLastModified: ComputedRef<number>;
/**
* Show the open-file picker and load the chosen file.
*/
open: (options?: UseFileSystemAccessCommonOptions) => Promise<void>;
/**
* Show the save-file picker to create a new, empty file handle.
*/
create: (options?: UseFileSystemAccessShowSaveFileOptions) => Promise<void>;
/**
* Write `data` back to the current handle (falls back to `saveAs` when none).
*/
save: (options?: UseFileSystemAccessShowSaveFileOptions) => Promise<void>;
/**
* Show the save-file picker, then write `data` to the chosen handle.
*/
saveAs: (options?: UseFileSystemAccessShowSaveFileOptions) => Promise<void>;
/**
* Re-read `data` (and metadata) from the current handle.
*/
updateData: () => Promise<void>;
}
/**
* @name useFileSystemAccess
* @category Browser
* @description Create, read, and write local files via the File System Access API.
*
* @param {UseFileSystemAccessOptions} [options={}] Options including `dataType`, `types`, `excludeAcceptAllOption`, and `onError`
* @returns {UseFileSystemAccessReturn} `isSupported`, `data`, `file`, `fileName`, `fileMIME`, `fileSize`, `fileLastModified`, `open`, `create`, `save`, `saveAs`, `updateData`
*
* @example
* const { isSupported, data, open, save } = useFileSystemAccess({ dataType: 'Text' });
* await open();
* data.value += '\nappended';
* await save();
*
* @example
* // Read raw bytes
* const { data } = useFileSystemAccess({ dataType: 'ArrayBuffer' });
*
* @since 0.0.15
*/
export function useFileSystemAccess(): UseFileSystemAccessReturn<string | ArrayBuffer | Blob>;
export function useFileSystemAccess(options: UseFileSystemAccessOptions & { dataType: 'Text' }): UseFileSystemAccessReturn<string>;
export function useFileSystemAccess(options: UseFileSystemAccessOptions & { dataType: 'ArrayBuffer' }): UseFileSystemAccessReturn<ArrayBuffer>;
export function useFileSystemAccess(options: UseFileSystemAccessOptions & { dataType: 'Blob' }): UseFileSystemAccessReturn<Blob>;
export function useFileSystemAccess(options: UseFileSystemAccessOptions): UseFileSystemAccessReturn<string | ArrayBuffer | Blob>;
export function useFileSystemAccess(
options: UseFileSystemAccessOptions = {},
): UseFileSystemAccessReturn<string | ArrayBuffer | Blob> {
const {
window: win = defaultWindow,
dataType = 'Text',
types,
excludeAcceptAllOption,
onError = noop,
} = options;
const fsWindow = win as FileSystemAccessWindow | undefined;
const isSupported = useSupported(() => fsWindow && 'showSaveFilePicker' in fsWindow && 'showOpenFilePicker' in fsWindow);
const fileHandle = shallowRef<FileSystemFileHandle>();
const data = shallowRef<string | ArrayBuffer | Blob>();
const file = shallowRef<File>();
const fileName = computed(() => file.value?.name ?? '');
const fileMIME = computed(() => file.value?.type ?? '');
const fileSize = computed(() => file.value?.size ?? 0);
const fileLastModified = computed(() => file.value?.lastModified ?? 0);
// Resolve the picker defaults once per call rather than spreading the full
// options bag (which carries `window`/`onError`) into the native picker.
function pickerDefaults(): UseFileSystemAccessCommonOptions {
return { types, excludeAcceptAllOption };
}
async function updateFile(): Promise<void> {
file.value = await fileHandle.value?.getFile();
}
async function updateData(): Promise<void> {
await updateFile();
const type = toValue(dataType);
if (type === 'Text')
data.value = await file.value?.text();
else if (type === 'ArrayBuffer')
data.value = await file.value?.arrayBuffer();
else if (type === 'Blob')
data.value = file.value;
}
async function writeData(): Promise<void> {
if (!fileHandle.value || data.value === undefined || data.value === null)
return;
const stream = await fileHandle.value.createWritable();
await stream.write(data.value);
await stream.close();
}
async function open(_options: UseFileSystemAccessCommonOptions = {}): Promise<void> {
if (!isSupported.value || !fsWindow)
return;
try {
const [handle] = await fsWindow.showOpenFilePicker({ ...pickerDefaults(), ..._options });
if (!handle)
return;
fileHandle.value = handle;
await updateData();
}
catch (error) {
onError(error);
}
}
async function create(_options: UseFileSystemAccessShowSaveFileOptions = {}): Promise<void> {
if (!isSupported.value || !fsWindow)
return;
try {
fileHandle.value = await fsWindow.showSaveFilePicker({ ...pickerDefaults(), ..._options });
data.value = undefined;
await updateData();
}
catch (error) {
onError(error);
}
}
async function saveAs(_options: UseFileSystemAccessShowSaveFileOptions = {}): Promise<void> {
if (!isSupported.value || !fsWindow)
return;
try {
fileHandle.value = await fsWindow.showSaveFilePicker({ ...pickerDefaults(), ..._options });
await writeData();
await updateFile();
}
catch (error) {
onError(error);
}
}
async function save(_options: UseFileSystemAccessShowSaveFileOptions = {}): Promise<void> {
if (!isSupported.value)
return;
if (!fileHandle.value)
return saveAs(_options);
try {
await writeData();
await updateFile();
}
catch (error) {
onError(error);
}
}
// Re-read with the new strategy whenever `dataType` changes; skip the redundant
// initial run since nothing is open yet.
watch(() => toValue(dataType), () => {
if (fileHandle.value)
void updateData();
});
return {
isSupported,
data,
file,
fileName,
fileMIME,
fileSize,
fileLastModified,
open,
create,
save,
saveAs,
updateData,
};
}
@@ -1,173 +0,0 @@
import { describe, expect, it } from 'vitest';
import { effectScope, nextTick, ref } from 'vue';
import { useFocus } from '.';
function host<T>(fn: () => T): { result: T; stop: () => void } {
const scope = effectScope();
let result!: T;
scope.run(() => {
result = fn();
});
return { result, stop: () => scope.stop() };
}
describe(useFocus, () => {
it('reflects focus and blur events on the target', async () => {
const input = document.createElement('input');
document.body.appendChild(input);
const { result, stop } = host(() => useFocus(input));
await nextTick();
expect(result.focused.value).toBeFalsy();
input.dispatchEvent(new FocusEvent('focus'));
expect(result.focused.value).toBeTruthy();
input.dispatchEvent(new FocusEvent('blur'));
expect(result.focused.value).toBeFalsy();
stop();
input.remove();
});
it('focuses the element when writing true', async () => {
const input = document.createElement('input');
document.body.appendChild(input);
const { result, stop } = host(() => useFocus(input));
await nextTick();
result.focused.value = true;
// jsdom dispatches the focus event synchronously from .focus()
expect(document.activeElement).toBe(input);
expect(result.focused.value).toBeTruthy();
stop();
input.remove();
});
it('blurs the element when writing false', async () => {
const input = document.createElement('input');
document.body.appendChild(input);
const { result, stop } = host(() => useFocus(input));
await nextTick();
result.focused.value = true;
expect(document.activeElement).toBe(input);
result.focused.value = false;
expect(document.activeElement).not.toBe(input);
expect(result.focused.value).toBeFalsy();
stop();
input.remove();
});
it('focuses on mount when initialValue is true', async () => {
const input = document.createElement('input');
document.body.appendChild(input);
const { result, stop } = host(() => useFocus(input, { initialValue: true }));
// the watch runs with flush: 'post'
await nextTick();
expect(document.activeElement).toBe(input);
expect(result.focused.value).toBeTruthy();
stop();
input.remove();
});
it('passes preventScroll to focus()', async () => {
const input = document.createElement('input');
document.body.appendChild(input);
let receivedOptions: FocusOptions | undefined;
const originalFocus = input.focus.bind(input);
input.focus = (opts?: FocusOptions) => {
receivedOptions = opts;
originalFocus(opts);
};
const { result, stop } = host(() => useFocus(input, { preventScroll: true }));
await nextTick();
result.focused.value = true;
expect(receivedOptions).toEqual({ preventScroll: true });
stop();
input.remove();
});
it('respects focusVisible by checking :focus-visible', async () => {
const input = document.createElement('input');
document.body.appendChild(input);
// emulate the element NOT matching :focus-visible (e.g. mouse focus)
input.matches = () => false;
const { result, stop } = host(() => useFocus(input, { focusVisible: true }));
await nextTick();
input.dispatchEvent(new FocusEvent('focus'));
// focus should be ignored because :focus-visible did not match
expect(result.focused.value).toBeFalsy();
// now emulate a keyboard focus that does match
input.matches = (selector: string) => selector === ':focus-visible';
input.dispatchEvent(new FocusEvent('focus'));
expect(result.focused.value).toBeTruthy();
stop();
input.remove();
});
it('tracks a reactive (changing) target', async () => {
const a = document.createElement('input');
const b = document.createElement('input');
document.body.append(a, b);
const target = ref<HTMLElement>(a);
const { result, stop } = host(() => useFocus(target));
await nextTick();
// start unfocused, then focus the first target
a.dispatchEvent(new FocusEvent('focus'));
expect(result.focused.value).toBeTruthy();
a.dispatchEvent(new FocusEvent('blur'));
expect(result.focused.value).toBeFalsy();
// swap the tracked target; listeners follow the reactive ref
target.value = b;
await nextTick();
// events on the new element are now tracked
b.dispatchEvent(new FocusEvent('focus'));
expect(result.focused.value).toBeTruthy();
b.dispatchEvent(new FocusEvent('blur'));
expect(result.focused.value).toBeFalsy();
// events on the old element no longer affect state
a.dispatchEvent(new FocusEvent('focus'));
expect(result.focused.value).toBeFalsy();
stop();
a.remove();
b.remove();
});
it('does not throw when the target is null', async () => {
const target = ref<HTMLElement | null>(null);
const { result, stop } = host(() => useFocus(target));
await nextTick();
expect(result.focused.value).toBeFalsy();
// writing to a null target must be a no-op, not a crash
expect(() => {
result.focused.value = true;
}).not.toThrow();
expect(result.focused.value).toBeFalsy();
stop();
});
});
@@ -1,113 +0,0 @@
import { computed, shallowRef, watch } from 'vue';
import type { WritableComputedRef } from 'vue';
import { unrefElement } from '@/composables/component/unrefElement';
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
import { useEventListener } from '@/composables/browser/useEventListener';
import type { ConfigurableWindow } from '@/types';
export interface UseFocusOptions extends ConfigurableWindow {
/**
* Initial focus state. When `true`, the element is focused on mount.
*
* @default false
*/
initialValue?: boolean;
/**
* Only consider the element focused when it matches `:focus-visible`,
* mirroring the browser's keyboard-focus heuristics.
*
* @default false
*/
focusVisible?: boolean;
/**
* Prevent the browser from scrolling the element into view when focusing it.
*
* @default false
*/
preventScroll?: boolean;
}
export interface UseFocusReturn {
/**
* Reactive focus state. Read it to know whether the target is focused, or
* write to it to programmatically focus (`true`) or blur (`false`) the target.
*/
focused: WritableComputedRef<boolean>;
}
/**
* @name useFocus
* @category Browser
* @description Reactive focus state of an element. The returned `focused` ref tracks
* focus/blur events and can be written to in order to focus or blur the target.
*
* @param {MaybeComputedElementRef} target - The element (or template ref) to track.
* @param {UseFocusOptions} [options={}] - Options
* @returns {UseFocusReturn} An object containing the writable `focused` ref.
*
* @example
* const el = useTemplateRef<HTMLInputElement>('el');
* const { focused } = useFocus(el);
* // focus the element imperatively
* focused.value = true;
*
* @example
* // only treat keyboard focus as focused
* const { focused } = useFocus(el, { focusVisible: true });
*
* @since 0.0.15
*/
export function useFocus(
target: MaybeComputedElementRef,
options: UseFocusOptions = {},
): UseFocusReturn {
const {
initialValue = false,
focusVisible = false,
preventScroll = false,
} = options;
const innerFocused = shallowRef(false);
const targetElement = computed(() => unrefElement(target) as HTMLElement | undefined | null);
const listenerOptions = { passive: true } as const;
useEventListener(
targetElement,
'focus',
(event: FocusEvent) => {
if (!focusVisible || (event.target as HTMLElement).matches?.(':focus-visible'))
innerFocused.value = true;
},
listenerOptions,
);
useEventListener(
targetElement,
'blur',
() => {
innerFocused.value = false;
},
listenerOptions,
);
const focused = computed<boolean>({
get: () => innerFocused.value,
set(value: boolean) {
if (!value && innerFocused.value)
targetElement.value?.blur();
else if (value && !innerFocused.value)
targetElement.value?.focus({ preventScroll });
},
});
watch(
targetElement,
() => {
focused.value = initialValue;
},
{ immediate: true, flush: 'post' },
);
return { focused };
}
@@ -1,69 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mount } from '@vue/test-utils';
import { defineComponent, nextTick } from 'vue';
import { useFocusGuard } from '.';
const setupFocusGuard = (namespace?: string) => {
return mount(
defineComponent({
setup() {
useFocusGuard(namespace);
},
template: '<div></div>',
}),
);
};
const getFocusGuards = (namespace: string) =>
document.querySelectorAll(`[data-${namespace}]`);
describe(useFocusGuard, () => {
let component: ReturnType<typeof setupFocusGuard>;
const namespace = 'test-guard';
beforeEach(() => {
document.body.innerHTML = '';
});
afterEach(() => {
component.unmount();
});
it('create focus guards when mounted', async () => {
component = setupFocusGuard(namespace);
const guards = getFocusGuards(namespace);
expect(guards).toHaveLength(2);
guards.forEach((guard) => {
expect(guard.getAttribute('tabindex')).toBe('0');
expect(guard.getAttribute('style')).toContain('opacity: 0');
});
});
it('remove focus guards when unmounted', () => {
component = setupFocusGuard(namespace);
component.unmount();
expect(getFocusGuards(namespace)).toHaveLength(0);
});
it('correctly manage multiple instances with the same namespace', () => {
const wrapper1 = setupFocusGuard(namespace);
const wrapper2 = setupFocusGuard(namespace);
// Guards should not be duplicated
expect(getFocusGuards(namespace)).toHaveLength(2);
wrapper1.unmount();
// Second instance still keeps the guards
expect(getFocusGuards(namespace)).toHaveLength(2);
wrapper2.unmount();
// No guards left after all instances are unmounted
expect(getFocusGuards(namespace)).toHaveLength(0);
});
});
@@ -1,40 +0,0 @@
import { focusGuard } from '@robonen/platform/browsers';
import { onMounted, onUnmounted } from 'vue';
// Global counter to drop the focus guards when the last instance is unmounted
let counter = 0;
/**
* @name useFocusGuard
* @category Browser
* @description Adds a pair of focus guards at the boundaries of the DOM tree to ensure consistent focus behavior
*
* @param {string} [namespace] - A namespace to group the focus guards
* @returns {void}
*
* @example
* useFocusGuard();
*
* @example
* useFocusGuard('my-namespace');
*
* @since 0.0.2
*/
export function useFocusGuard(namespace?: string) {
const manager = focusGuard(namespace);
const createGuard = () => {
manager.createGuard();
counter++;
};
const removeGuard = () => {
if (counter <= 1)
manager.removeGuard();
counter = Math.max(0, counter - 1);
};
onMounted(createGuard);
onUnmounted(removeGuard);
}
@@ -1,194 +0,0 @@
import { describe, expect, it } from 'vitest';
import { effectScope, nextTick, shallowRef } from 'vue';
import { useFocusWithin } from '.';
function makeTree(): { container: HTMLDivElement; input: HTMLInputElement; outside: HTMLButtonElement } {
const container = document.createElement('div');
const input = document.createElement('input');
container.appendChild(input);
const outside = document.createElement('button');
document.body.appendChild(container);
document.body.appendChild(outside);
return { container, input, outside };
}
describe(useFocusWithin, () => {
it('is not focused initially when nothing inside has focus', () => {
const { container } = makeTree();
const scope = effectScope();
let result: ReturnType<typeof useFocusWithin>;
scope.run(() => {
result = useFocusWithin(container);
});
expect(result!.focused.value).toBeFalsy();
scope.stop();
document.body.replaceChildren();
});
it('becomes focused when a descendant receives focus', async () => {
const { container, input } = makeTree();
const scope = effectScope();
let result: ReturnType<typeof useFocusWithin>;
scope.run(() => {
result = useFocusWithin(container);
});
input.focus();
container.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
await nextTick();
expect(result!.focused.value).toBeTruthy();
scope.stop();
document.body.replaceChildren();
});
it('is focused when the target element itself receives focus', async () => {
const { container } = makeTree();
container.tabIndex = 0;
const scope = effectScope();
let result: ReturnType<typeof useFocusWithin>;
scope.run(() => {
result = useFocusWithin(container);
});
container.focus();
container.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
await nextTick();
expect(result!.focused.value).toBeTruthy();
scope.stop();
document.body.replaceChildren();
});
it('clears focus when focus leaves the element entirely', async () => {
const { container, input, outside } = makeTree();
const scope = effectScope();
let result: ReturnType<typeof useFocusWithin>;
scope.run(() => {
result = useFocusWithin(container);
});
input.focus();
container.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
await nextTick();
expect(result!.focused.value).toBeTruthy();
// Move focus to an element outside the container.
outside.focus();
container.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: outside }));
await nextTick();
expect(result!.focused.value).toBeFalsy();
scope.stop();
document.body.replaceChildren();
});
it('stays focused when focus moves between descendants', async () => {
const { container, input } = makeTree();
const second = document.createElement('input');
container.appendChild(second);
const scope = effectScope();
let result: ReturnType<typeof useFocusWithin>;
scope.run(() => {
result = useFocusWithin(container);
});
input.focus();
container.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
await nextTick();
expect(result!.focused.value).toBeTruthy();
// focusout fires as focus shifts, but the second input is still inside
// the container, so `:focus-within` keeps the state true.
second.focus();
container.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: second }));
await nextTick();
expect(result!.focused.value).toBeTruthy();
scope.stop();
document.body.replaceChildren();
});
it('reflects focus that already lives inside the target on creation', () => {
const { container, input } = makeTree();
input.focus();
const scope = effectScope();
let result: ReturnType<typeof useFocusWithin>;
scope.run(() => {
result = useFocusWithin(container);
});
expect(result!.focused.value).toBeTruthy();
scope.stop();
document.body.replaceChildren();
});
it('accepts a reactive ref target', async () => {
const { container, input } = makeTree();
const targetRef = shallowRef<HTMLElement | null>(container);
const scope = effectScope();
let result: ReturnType<typeof useFocusWithin>;
scope.run(() => {
result = useFocusWithin(targetRef);
});
input.focus();
container.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
await nextTick();
expect(result!.focused.value).toBeTruthy();
scope.stop();
document.body.replaceChildren();
});
it('exposes a read-only computed (write throws / has no setter)', () => {
const { container } = makeTree();
const scope = effectScope();
let result: ReturnType<typeof useFocusWithin>;
scope.run(() => {
result = useFocusWithin(container);
});
// computed without a setter ignores writes (does not mutate internal state)
// @ts-expect-error - intentionally writing to a read-only computed
result!.focused.value = true;
expect(result!.focused.value).toBeFalsy();
scope.stop();
document.body.replaceChildren();
});
it('does not throw and stays false when window is unavailable (SSR)', () => {
const { container } = makeTree();
const scope = effectScope();
let result: ReturnType<typeof useFocusWithin>;
scope.run(() => {
result = useFocusWithin(container, { window: undefined });
});
expect(result!.focused.value).toBeFalsy();
scope.stop();
document.body.replaceChildren();
});
});
@@ -1,83 +0,0 @@
import { computed, shallowRef } from 'vue';
import type { ComputedRef } from 'vue';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { unrefElement } from '@/composables/component/unrefElement';
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
import { useEventListener } from '@/composables/browser/useEventListener';
export interface UseFocusWithinOptions extends ConfigurableWindow {}
export interface UseFocusWithinReturn {
/**
* Whether the element or any of its descendants currently hold focus.
*/
focused: ComputedRef<boolean>;
}
/**
* @name useFocusWithin
* @category Browser
* @description Reactive tracking of whether an element or any of its
* descendants are focused, backed by the `focusin`/`focusout` events.
*
* @param {MaybeComputedElementRef} target Element to track
* @param {UseFocusWithinOptions} [options={}] Options
* @returns {UseFocusWithinReturn} `{ focused }` reactive focus-within state
*
* @example
* const el = useTemplateRef<HTMLElement>('el');
* const { focused } = useFocusWithin(el);
*
* @since 0.0.15
*/
export function useFocusWithin(
target: MaybeComputedElementRef,
options: UseFocusWithinOptions = {},
): UseFocusWithinReturn {
const { window = defaultWindow } = options;
const _focused = shallowRef(false);
const focused = computed(() => _focused.value);
const activeElement = window?.document?.activeElement;
const targetElement = computed(() => unrefElement(target) as HTMLElement | undefined | null);
if (window) {
useEventListener(targetElement, 'focusin', () => {
_focused.value = true;
}, { passive: true });
useEventListener(targetElement, 'focusout', (event: FocusEvent) => {
// After focus leaves a descendant, confirm focus did not simply move to
// another descendant. `event.relatedTarget` carries the element about to
// receive focus; if it is still inside `target` we remain focused. We
// also consult the `:focus-within` pseudo-class as a fallback for cases
// where `relatedTarget` is unavailable.
const el = unrefElement(target);
if (!el) {
_focused.value = false;
return;
}
const next = event.relatedTarget as Node | null;
if (next && el.contains(next)) {
_focused.value = true;
return;
}
_focused.value = el.matches?.(':focus-within') ?? false;
}, { passive: true });
}
// Reflect focus that already lives inside the target on initialization.
const el = unrefElement(target);
if (el && activeElement && (el === activeElement || el.contains(activeElement)))
_focused.value = true;
return {
focused,
};
}
@@ -1,244 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope } from 'vue';
import { useFps } from '.';
let rafCallbacks: Array<(time: number) => void> = [];
let rafIdCounter = 0;
beforeEach(() => {
rafCallbacks = [];
rafIdCounter = 0;
vi.stubGlobal('requestAnimationFrame', (cb: (time: number) => void) => {
const id = ++rafIdCounter;
rafCallbacks.push(cb);
return id;
});
vi.stubGlobal('cancelAnimationFrame', vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
});
function triggerFrame(time: number) {
const cbs = [...rafCallbacks];
rafCallbacks = [];
cbs.forEach(cb => cb(time));
}
function triggerFrames(startTime: number, interval: number, count: number) {
for (let i = 0; i < count; i++) {
triggerFrame(startTime + i * interval);
}
}
describe(useFps, () => {
it('starts at 0 fps', () => {
const scope = effectScope();
let result: ReturnType<typeof useFps>;
scope.run(() => {
result = useFps();
});
expect(result!.fps.value).toBe(0);
scope.stop();
});
it('reports fps after "every" frames', () => {
const scope = effectScope();
let result: ReturnType<typeof useFps>;
scope.run(() => {
result = useFps({ every: 5 });
});
// ~60fps = 16.67ms per frame
// First frame has delta=0, skipped by useFps. Need 5 real-delta frames.
triggerFrame(100); // delta=0, skipped
triggerFrame(116.67); // delta=16.67
triggerFrame(133.33); // delta=16.66
triggerFrame(150); // delta=16.67
triggerFrame(166.67); // delta=16.67
triggerFrame(183.33); // delta=16.66 → 5 deltas collected, update
expect(result!.fps.value).toBe(60);
scope.stop();
});
it('does not update fps before collecting enough frames', () => {
const scope = effectScope();
let result: ReturnType<typeof useFps>;
scope.run(() => {
result = useFps({ every: 10 });
});
triggerFrame(100);
triggerFrame(116.67);
triggerFrame(133.33);
expect(result!.fps.value).toBe(0);
scope.stop();
});
it('tracks min and max fps', () => {
const scope = effectScope();
let result: ReturnType<typeof useFps>;
scope.run(() => {
result = useFps({ every: 3 });
});
// First batch: ~60fps (16.67ms intervals)
triggerFrame(100); // delta=0, skipped
triggerFrame(116.67); // delta=16.67
triggerFrame(133.33); // delta=16.66
triggerFrame(150); // delta=16.67 → 3 deltas, update
const firstFps = result!.fps.value;
expect(firstFps).toBe(60);
// Second batch: ~30fps (33.33ms intervals)
triggerFrame(183.33); // delta=33.33
triggerFrame(216.67); // delta=33.34
triggerFrame(250); // delta=33.33 → 3 deltas, update
const secondFps = result!.fps.value;
expect(secondFps).toBe(30);
expect(result!.max.value).toBe(60);
expect(result!.min.value).toBe(30);
scope.stop();
});
it('resets min, max, and fps', () => {
const scope = effectScope();
let result: ReturnType<typeof useFps>;
scope.run(() => {
result = useFps({ every: 3 });
});
triggerFrame(100);
triggerFrame(116.67);
triggerFrame(133.33);
triggerFrame(150);
expect(result!.fps.value).toBe(60);
result!.reset();
expect(result!.fps.value).toBe(0);
expect(result!.min.value).toBe(Infinity);
expect(result!.max.value).toBe(0);
scope.stop();
});
it('cleans up on scope dispose', () => {
const scope = effectScope();
scope.run(() => {
useFps();
});
// Should not throw on stop
scope.stop();
// No more raf callbacks should be registered after stop
triggerFrame(100);
expect(rafCallbacks).toHaveLength(0);
});
it('does nothing when window is undefined (SSR)', () => {
const scope = effectScope();
let result: ReturnType<typeof useFps>;
scope.run(() => {
result = useFps({ window: undefined as any });
});
expect(result!.fps.value).toBe(0);
scope.stop();
});
it('is active by default', () => {
const scope = effectScope();
let result: ReturnType<typeof useFps>;
scope.run(() => {
result = useFps();
});
expect(result!.isActive.value).toBeTruthy();
scope.stop();
});
it('does not start when immediate is false', () => {
const scope = effectScope();
let result: ReturnType<typeof useFps>;
scope.run(() => {
result = useFps({ immediate: false });
});
expect(result!.isActive.value).toBeFalsy();
scope.stop();
});
it('pauses and resumes fps tracking', () => {
const scope = effectScope();
let result: ReturnType<typeof useFps>;
scope.run(() => {
result = useFps({ every: 3 });
});
// Collect one batch
triggerFrame(100);
triggerFrame(116.67);
triggerFrame(133.33);
triggerFrame(150);
expect(result!.fps.value).toBe(60);
result!.pause();
expect(result!.isActive.value).toBeFalsy();
// Frames while paused should not update
triggerFrame(200);
triggerFrame(300);
triggerFrame(400);
triggerFrame(500);
expect(result!.fps.value).toBe(60);
result!.resume();
expect(result!.isActive.value).toBeTruthy();
scope.stop();
});
it('toggles fps tracking', () => {
const scope = effectScope();
let result: ReturnType<typeof useFps>;
scope.run(() => {
result = useFps();
});
expect(result!.isActive.value).toBeTruthy();
result!.toggle();
expect(result!.isActive.value).toBeFalsy();
result!.toggle();
expect(result!.isActive.value).toBeTruthy();
scope.stop();
});
});
@@ -1,109 +0,0 @@
import type { ConfigurableWindow, ResumableActions, ResumableOptions } from '@/types';
import type { UseRafFnCallbackArgs } from '@/composables/browser/useRafFn';
import type { Ref } from 'vue';
import { ref } from 'vue';
import { useRafFn } from '@/composables/browser/useRafFn';
export interface UseFpsOptions extends ResumableOptions, ConfigurableWindow {
/**
* Number of frames to average over for a smoother reading.
*
* @default 10
*/
every?: number;
}
export interface UseFpsReturn extends ResumableActions {
/**
* Current frames per second (averaged over the last `every` frames)
*/
fps: Readonly<Ref<number>>;
/**
* Minimum FPS recorded since the composable was created or last reset
*/
min: Readonly<Ref<number>>;
/**
* Maximum FPS recorded since the composable was created or last reset
*/
max: Readonly<Ref<number>>;
/**
* Whether the FPS counter is currently active
*/
isActive: Readonly<Ref<boolean>>;
/**
* Reset min/max tracking
*/
reset: () => void;
}
/**
* Reactive FPS counter based on `requestAnimationFrame`.
* Reports a smoothed FPS value averaged over a configurable number of frames,
* and tracks min/max values.
*
* @param options - Configuration options
*
* @example
* ```ts
* const { fps, min, max, reset } = useFps();
* ```
*/
export function useFps(options: UseFpsOptions = {}): UseFpsReturn {
const { every = 10, ...rafOptions } = options;
const fps = ref(0);
const min = ref(Infinity);
const max = ref(0);
let deltaSum = 0;
let frameCount = 0;
function update({ delta }: UseRafFnCallbackArgs) {
if (!delta)
return;
deltaSum += delta;
frameCount++;
if (frameCount < every)
return;
const currentFps = Math.round(1000 / (deltaSum / frameCount));
fps.value = currentFps;
if (currentFps < min.value)
min.value = currentFps;
if (currentFps > max.value)
max.value = currentFps;
deltaSum = 0;
frameCount = 0;
}
function reset() {
min.value = Infinity;
max.value = 0;
fps.value = 0;
deltaSum = 0;
frameCount = 0;
}
const { isActive, pause, resume, toggle } = useRafFn(update, rafOptions);
return {
fps,
min,
max,
isActive,
reset,
pause,
resume,
toggle,
};
}
@@ -6,7 +6,7 @@ import { defaultDocument } from '@/types';
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
import { unrefElement } from '@/composables/component/unrefElement';
import { useEventListener } from '@/composables/browser/useEventListener';
import { useSupported } from '@/composables/browser/useSupported';
import { useSupported } from '@/composables/utilities/useSupported';
import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
@@ -1,266 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, shallowRef } from 'vue';
import { useGeolocation } from '.';
afterEach(() => vi.unstubAllGlobals());
function stubGeolocation() {
let successCb: PositionCallback | null = null;
let errorCb: PositionErrorCallback | null = null;
let lastOptions: PositionOptions | undefined;
const watchPosition = vi.fn((success: PositionCallback, err?: PositionErrorCallback | null, opts?: PositionOptions) => {
successCb = success;
errorCb = err ?? null;
lastOptions = opts;
return 1;
});
const clearWatch = vi.fn();
const getCurrentPosition = vi.fn((success: PositionCallback, err?: PositionErrorCallback | null, opts?: PositionOptions) => {
successCb = success;
errorCb = err ?? null;
lastOptions = opts;
});
const navigator = { geolocation: { watchPosition, clearWatch, getCurrentPosition } } as unknown as Navigator;
return {
navigator,
watchPosition,
clearWatch,
getCurrentPosition,
getOptions: () => lastOptions,
emit: (position: GeolocationPosition) => successCb?.(position),
emitError: (err: GeolocationPositionError) => errorCb?.(err),
};
}
function makePosition(latitude: number, longitude: number, timestamp = 123): GeolocationPosition {
return {
timestamp,
coords: {
latitude,
longitude,
accuracy: 5,
altitude: null,
altitudeAccuracy: null,
heading: null,
speed: null,
} as GeolocationCoordinates,
} as GeolocationPosition;
}
function makeError(code = 1, message = 'denied'): GeolocationPositionError {
return { code, message, PERMISSION_DENIED: 1, POSITION_UNAVAILABLE: 2, TIMEOUT: 3 } as GeolocationPositionError;
}
describe(useGeolocation, () => {
it('starts watching immediately by default', () => {
const { watchPosition, navigator } = stubGeolocation();
const scope = effectScope();
scope.run(() => useGeolocation({ navigator }));
expect(watchPosition).toHaveBeenCalled();
scope.stop();
});
it('does not start watching when immediate is false', () => {
const { watchPosition, navigator } = stubGeolocation();
const scope = effectScope();
let geo: ReturnType<typeof useGeolocation>;
scope.run(() => {
geo = useGeolocation({ navigator, immediate: false });
});
expect(watchPosition).not.toHaveBeenCalled();
expect(geo!.isActive.value).toBeFalsy();
scope.stop();
});
it('updates coords on position change', () => {
const { emit, navigator } = stubGeolocation();
const scope = effectScope();
let geo: ReturnType<typeof useGeolocation>;
scope.run(() => {
geo = useGeolocation({ navigator });
});
emit(makePosition(1, 2));
expect(geo!.coords.value.latitude).toBe(1);
expect(geo!.coords.value.longitude).toBe(2);
expect(geo!.locatedAt.value).toBe(123);
expect(geo!.ready.value).toBeTruthy();
scope.stop();
});
it('tracks active state across resume/pause', () => {
const { navigator } = stubGeolocation();
const scope = effectScope();
let geo: ReturnType<typeof useGeolocation>;
scope.run(() => {
geo = useGeolocation({ navigator });
});
expect(geo!.isActive.value).toBeTruthy();
geo!.pause();
expect(geo!.isActive.value).toBeFalsy();
geo!.resume();
expect(geo!.isActive.value).toBeTruthy();
scope.stop();
});
it('clears the watch on pause', () => {
const { clearWatch, navigator } = stubGeolocation();
const scope = effectScope();
let geo: ReturnType<typeof useGeolocation>;
scope.run(() => {
geo = useGeolocation({ navigator });
});
geo!.pause();
expect(clearWatch).toHaveBeenCalledWith(1);
scope.stop();
});
it('does not re-watch when already active', () => {
const { watchPosition, navigator } = stubGeolocation();
const scope = effectScope();
let geo: ReturnType<typeof useGeolocation>;
scope.run(() => {
geo = useGeolocation({ navigator });
});
geo!.resume();
geo!.resume();
expect(watchPosition).toHaveBeenCalledTimes(1);
scope.stop();
});
it('records errors and invokes onError', () => {
const { emitError, navigator } = stubGeolocation();
const onError = vi.fn();
const scope = effectScope();
let geo: ReturnType<typeof useGeolocation>;
scope.run(() => {
geo = useGeolocation({ navigator, onError });
});
const err = makeError();
emitError(err);
expect(geo!.error.value).toBe(err);
expect(onError).toHaveBeenCalledWith(err);
scope.stop();
});
it('clears the error after a successful fix', () => {
const { emit, emitError, navigator } = stubGeolocation();
const scope = effectScope();
let geo: ReturnType<typeof useGeolocation>;
scope.run(() => {
geo = useGeolocation({ navigator });
});
emitError(makeError());
expect(geo!.error.value).not.toBeNull();
emit(makePosition(1, 2));
expect(geo!.error.value).toBeNull();
scope.stop();
});
it('passes position options to watchPosition', () => {
const { getOptions, navigator } = stubGeolocation();
const scope = effectScope();
scope.run(() => useGeolocation({ navigator, enableHighAccuracy: false, maximumAge: 1000, timeout: 5000 }));
expect(getOptions()).toEqual({ enableHighAccuracy: false, maximumAge: 1000, timeout: 5000 });
scope.stop();
});
it('restarts the watcher when reactive options change while active', async () => {
const { watchPosition, clearWatch, getOptions, navigator } = stubGeolocation();
const highAccuracy = shallowRef(false);
const scope = effectScope();
scope.run(() => useGeolocation({ navigator, enableHighAccuracy: highAccuracy }));
expect(watchPosition).toHaveBeenCalledTimes(1);
expect(getOptions()?.enableHighAccuracy).toBeFalsy();
highAccuracy.value = true;
await nextTick();
expect(clearWatch).toHaveBeenCalledWith(1);
expect(watchPosition).toHaveBeenCalledTimes(2);
expect(getOptions()?.enableHighAccuracy).toBeTruthy();
scope.stop();
});
it('does not restart on option change when paused', async () => {
const { watchPosition, navigator } = stubGeolocation();
const timeout = shallowRef(1000);
const scope = effectScope();
let geo: ReturnType<typeof useGeolocation>;
scope.run(() => {
geo = useGeolocation({ navigator, timeout, immediate: false });
});
timeout.value = 2000;
await nextTick();
expect(watchPosition).not.toHaveBeenCalled();
expect(geo!.isActive.value).toBeFalsy();
scope.stop();
});
it('getCurrentPosition resolves and updates state without a watch', async () => {
const { watchPosition, getCurrentPosition, emit, navigator } = stubGeolocation();
const scope = effectScope();
let geo: ReturnType<typeof useGeolocation>;
scope.run(() => {
geo = useGeolocation({ navigator, immediate: false });
});
const promise = geo!.getCurrentPosition();
emit(makePosition(10, 20, 999));
const position = await promise;
expect(getCurrentPosition).toHaveBeenCalled();
expect(watchPosition).not.toHaveBeenCalled();
expect(position.coords.latitude).toBe(10);
expect(geo!.coords.value.latitude).toBe(10);
expect(geo!.locatedAt.value).toBe(999);
expect(geo!.ready.value).toBeTruthy();
scope.stop();
});
it('getCurrentPosition rejects on error', async () => {
const { emitError, navigator } = stubGeolocation();
const scope = effectScope();
let geo: ReturnType<typeof useGeolocation>;
scope.run(() => {
geo = useGeolocation({ navigator, immediate: false });
});
const promise = geo!.getCurrentPosition();
const err = makeError();
emitError(err);
await expect(promise).rejects.toBe(err);
expect(geo!.error.value).toBe(err);
scope.stop();
});
it('reports unsupported when geolocation is missing', () => {
const navigator = {} as Navigator;
const scope = effectScope();
let geo: ReturnType<typeof useGeolocation>;
scope.run(() => {
geo = useGeolocation({ navigator });
});
expect(geo!.isSupported.value).toBeFalsy();
scope.stop();
});
it('getCurrentPosition rejects when unsupported', async () => {
const navigator = {} as Navigator;
const scope = effectScope();
let geo: ReturnType<typeof useGeolocation>;
scope.run(() => {
geo = useGeolocation({ navigator, immediate: false });
});
await expect(geo!.getCurrentPosition()).rejects.toThrow('not supported');
scope.stop();
});
});
@@ -1,236 +0,0 @@
import { shallowReadonly, shallowRef, toValue, watch } from 'vue';
import type { MaybeRefOrGetter, Ref } from 'vue';
import { noop } from '@robonen/stdlib';
import { defaultNavigator } from '@/types';
import type { ConfigurableNavigator } from '@/types';
import { useSupported } from '@/composables/browser/useSupported';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
export interface UseGeolocationOptions extends ConfigurableNavigator {
/**
* A boolean that indicates the application would like to receive the best
* possible results. Reactive — changing it while watching restarts the watcher.
*
* @default true
*/
enableHighAccuracy?: MaybeRefOrGetter<boolean>;
/**
* The maximum age in milliseconds of a possible cached position that is
* acceptable to return. Reactive — changing it while watching restarts the watcher.
*
* @default 30000
*/
maximumAge?: MaybeRefOrGetter<number>;
/**
* The maximum length of time in milliseconds the device is allowed to take in
* order to return a position. Reactive — changing it while watching restarts the watcher.
*
* @default 27000
*/
timeout?: MaybeRefOrGetter<number>;
/**
* Start watching the position immediately
*
* @default true
*/
immediate?: boolean;
/**
* Called whenever the Geolocation API reports an error. Receives the
* `GeolocationPositionError`. Useful for reacting to permission denials or
* timeouts without setting up a watcher on `error`.
*
* @default () => {}
*/
onError?: (error: GeolocationPositionError) => void;
}
export interface UseGeolocationReturn {
/**
* Whether the Geolocation API is supported in the current environment.
*/
isSupported: Readonly<Ref<boolean>>;
/**
* The most recent set of coordinates.
*/
coords: Readonly<Ref<Omit<GeolocationPosition['coords'], 'toJSON'>>>;
/**
* The timestamp of the most recent position, or `null` before the first fix.
*/
locatedAt: Readonly<Ref<number | null>>;
/**
* The most recent error, or `null` if none.
*/
error: Readonly<Ref<GeolocationPositionError | null>>;
/**
* Whether at least one position fix has been received.
*/
ready: Readonly<Ref<boolean>>;
/**
* Whether the position is currently being watched.
*/
isActive: Readonly<Ref<boolean>>;
/**
* Start watching the position.
*/
resume: () => void;
/**
* Stop watching the position.
*/
pause: () => void;
/**
* Request the current position once, without starting a continuous watch.
* Resolves with the position (and updates `coords`/`locatedAt`) or rejects
* with a `GeolocationPositionError`.
*/
getCurrentPosition: () => Promise<GeolocationPosition>;
}
const DEFAULT_COORDS: Omit<GeolocationPosition['coords'], 'toJSON'> = {
accuracy: 0,
latitude: Number.POSITIVE_INFINITY,
longitude: Number.POSITIVE_INFINITY,
altitude: null,
altitudeAccuracy: null,
heading: null,
speed: null,
};
/**
* @name useGeolocation
* @category Browser
* @description Reactive Geolocation API. Watches the device position, exposing
* reactive coordinates, error, and readiness state, plus pause/resume controls
* and a one-shot `getCurrentPosition`.
*
* @param {UseGeolocationOptions} [options={}] Options
* @returns {UseGeolocationReturn} Reactive position, error, readiness, and watch controls
*
* @example
* const { coords, locatedAt, error, ready } = useGeolocation();
*
* @example
* // One-shot fetch without a continuous watch
* const { getCurrentPosition } = useGeolocation({ immediate: false });
* const position = await getCurrentPosition();
*
* @since 0.0.15
*/
export function useGeolocation(options: UseGeolocationOptions = {}): UseGeolocationReturn {
const {
enableHighAccuracy = true,
maximumAge = 30000,
timeout = 27000,
navigator = defaultNavigator,
immediate = true,
onError = noop,
} = options;
const isSupported = useSupported(() => navigator && 'geolocation' in navigator);
const locatedAt = shallowRef<number | null>(null);
const error = shallowRef<GeolocationPositionError | null>(null);
const coords = shallowRef<Omit<GeolocationPosition['coords'], 'toJSON'>>(DEFAULT_COORDS);
const ready = shallowRef(false);
const isActive = shallowRef(false);
function resolveOptions(): PositionOptions {
return {
enableHighAccuracy: toValue(enableHighAccuracy),
maximumAge: toValue(maximumAge),
timeout: toValue(timeout),
};
}
function updatePosition(position: GeolocationPosition): void {
locatedAt.value = position.timestamp;
coords.value = position.coords;
error.value = null;
ready.value = true;
}
function handleError(err: GeolocationPositionError): void {
error.value = err;
onError(err);
}
let watcher: number | null = null;
function resume(): void {
if (!isSupported.value || !navigator || watcher !== null)
return;
watcher = navigator.geolocation.watchPosition(updatePosition, handleError, resolveOptions());
isActive.value = true;
}
function pause(): void {
if (watcher === null || !navigator)
return;
navigator.geolocation.clearWatch(watcher);
watcher = null;
isActive.value = false;
}
function getCurrentPosition(): Promise<GeolocationPosition> {
return new Promise((resolve, reject) => {
if (!isSupported.value || !navigator) {
reject(new Error('Geolocation is not supported'));
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
updatePosition(position);
resolve(position);
},
(err) => {
handleError(err);
reject(err);
},
resolveOptions(),
);
});
}
// Restart the watcher when reactive position options change while active.
watch(
() => resolveOptions(),
() => {
if (isActive.value) {
pause();
resume();
}
},
{ deep: true },
);
if (immediate)
resume();
tryOnScopeDispose(pause);
return {
isSupported,
coords,
locatedAt: shallowReadonly(locatedAt),
error: shallowReadonly(error),
ready: shallowReadonly(ready),
isActive: shallowReadonly(isActive),
resume,
pause,
getCurrentPosition,
};
}
@@ -1,259 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope } from 'vue';
import { useIdle } from '.';
import { bypassFilter } from '@/utils/filters';
/**
* Minimal EventTarget-like stub so we can drive listeners deterministically
* without relying on jsdom's real window/document timing.
*/
function createTarget() {
const listeners = new Map<string, Set<EventListener>>();
return {
addEventListener(type: string, listener: EventListener) {
if (!listeners.has(type))
listeners.set(type, new Set());
listeners.get(type)!.add(listener);
},
removeEventListener(type: string, listener: EventListener) {
listeners.get(type)?.delete(listener);
},
dispatch(type: string, event: Event = { type } as Event) {
listeners.get(type)?.forEach(fn => fn(event));
},
count(type: string) {
return listeners.get(type)?.size ?? 0;
},
};
}
function createWindow(doc: ReturnType<typeof createTarget> & { hidden?: boolean }) {
const win = createTarget() as ReturnType<typeof createTarget> & { document: typeof doc };
win.document = doc;
return win;
}
describe(useIdle, () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(1000);
});
afterEach(() => vi.useRealTimers());
it('starts not idle and exposes lastActive', () => {
const scope = effectScope();
scope.run(() => {
const doc = createTarget();
const window = createWindow(doc) as any;
const { idle, lastActive, isPending } = useIdle(1000, { window });
expect(idle.value).toBeFalsy();
expect(isPending.value).toBeTruthy();
expect(lastActive.value).toBe(1000);
});
scope.stop();
});
it('becomes idle after the timeout elapses with no activity', () => {
const scope = effectScope();
scope.run(() => {
const doc = createTarget();
const window = createWindow(doc) as any;
const { idle } = useIdle(1000, { window });
expect(idle.value).toBeFalsy();
vi.advanceTimersByTime(999);
expect(idle.value).toBeFalsy();
vi.advanceTimersByTime(1);
expect(idle.value).toBeTruthy();
});
scope.stop();
});
it('resets idle state and lastActive on user activity', () => {
const scope = effectScope();
scope.run(() => {
const doc = createTarget();
const window = createWindow(doc) as any;
const { idle, lastActive } = useIdle(1000, { window, eventFilter: bypassFilter });
vi.advanceTimersByTime(1000);
expect(idle.value).toBeTruthy();
vi.setSystemTime(2500);
window.dispatch('mousemove');
expect(idle.value).toBeFalsy();
expect(lastActive.value).toBe(2500);
});
scope.stop();
});
it('restarts the timeout after activity', () => {
const scope = effectScope();
scope.run(() => {
const doc = createTarget();
const window = createWindow(doc) as any;
const { idle } = useIdle(1000, { window, eventFilter: bypassFilter });
vi.advanceTimersByTime(500);
window.dispatch('keydown');
// 500ms more would have been idle without the reset
vi.advanceTimersByTime(500);
expect(idle.value).toBeFalsy();
// full timeout from the reset
vi.advanceTimersByTime(500);
expect(idle.value).toBeTruthy();
});
scope.stop();
});
it('honors a custom events list', () => {
const scope = effectScope();
scope.run(() => {
const doc = createTarget();
const window = createWindow(doc) as any;
useIdle(1000, { window, events: ['keydown'] });
expect(window.count('keydown')).toBe(1);
expect(window.count('mousemove')).toBe(0);
});
scope.stop();
});
it('listens for visibilitychange by default and resets when visible', () => {
const scope = effectScope();
scope.run(() => {
const doc = createTarget() as any;
doc.hidden = false;
const window = createWindow(doc) as any;
const { idle } = useIdle(1000, { window, eventFilter: bypassFilter });
expect(doc.count('visibilitychange')).toBe(1);
vi.advanceTimersByTime(1000);
expect(idle.value).toBeTruthy();
doc.dispatch('visibilitychange');
expect(idle.value).toBeFalsy();
});
scope.stop();
});
it('ignores visibilitychange when the document is hidden', () => {
const scope = effectScope();
scope.run(() => {
const doc = createTarget() as any;
doc.hidden = true;
const window = createWindow(doc) as any;
const { idle } = useIdle(1000, { window, eventFilter: bypassFilter });
vi.advanceTimersByTime(1000);
expect(idle.value).toBeTruthy();
doc.dispatch('visibilitychange');
expect(idle.value).toBeTruthy();
});
scope.stop();
});
it('does not register visibilitychange when disabled', () => {
const scope = effectScope();
scope.run(() => {
const doc = createTarget();
const window = createWindow(doc) as any;
useIdle(1000, { window, listenForVisibilityChange: false });
expect(doc.count('visibilitychange')).toBe(0);
});
scope.stop();
});
it('respects initialState: true (starts idle, no timer)', () => {
const scope = effectScope();
scope.run(() => {
const doc = createTarget();
const window = createWindow(doc) as any;
const { idle } = useIdle(1000, { window, initialState: true });
expect(idle.value).toBeTruthy();
vi.advanceTimersByTime(5000);
// no reset was scheduled, so it stays in the initial state
expect(idle.value).toBeTruthy();
});
scope.stop();
});
it('reset() manually clears idle and restarts the timer', () => {
const scope = effectScope();
scope.run(() => {
const doc = createTarget();
const window = createWindow(doc) as any;
const { idle, reset } = useIdle(1000, { window });
vi.advanceTimersByTime(1000);
expect(idle.value).toBeTruthy();
reset();
expect(idle.value).toBeFalsy();
vi.advanceTimersByTime(1000);
expect(idle.value).toBeTruthy();
});
scope.stop();
});
it('stop() halts tracking and start() resumes it', () => {
const scope = effectScope();
scope.run(() => {
const doc = createTarget();
const window = createWindow(doc) as any;
const { idle, isPending, start, stop } = useIdle(1000, { window, eventFilter: bypassFilter });
stop();
expect(isPending.value).toBeFalsy();
expect(idle.value).toBeFalsy();
// events are ignored while stopped
vi.advanceTimersByTime(1000);
expect(idle.value).toBeFalsy();
window.dispatch('mousemove');
expect(idle.value).toBeFalsy();
start();
expect(isPending.value).toBeTruthy();
vi.advanceTimersByTime(1000);
expect(idle.value).toBeTruthy();
});
scope.stop();
});
it('removes listeners when the scope is disposed', () => {
const scope = effectScope();
let win: any;
scope.run(() => {
const doc = createTarget();
win = createWindow(doc) as any;
useIdle(1000, { window: win });
expect(win.count('mousemove')).toBe(1);
});
scope.stop();
expect(win.count('mousemove')).toBe(0);
});
it('is SSR-safe when no window is available', () => {
const scope = effectScope();
scope.run(() => {
// simulate an environment with no window (the guard sees a falsy target)
const { idle, isPending, lastActive } = useIdle(1000, { window: null as any });
// never started: stays in initial state and never schedules a timer
expect(idle.value).toBeFalsy();
expect(isPending.value).toBeFalsy();
expect(lastActive.value).toBe(1000);
vi.advanceTimersByTime(5000);
expect(idle.value).toBeFalsy();
});
scope.stop();
});
});
@@ -1,169 +0,0 @@
import { shallowReadonly, shallowRef } from 'vue';
import type { ShallowRef } from 'vue';
import { timestamp } from '@robonen/stdlib';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { createFilterWrapper, throttleFilter } from '@/utils/filters';
import type { ConfigurableEventFilter } from '@/utils/filters';
import { useEventListener } from '@/composables/browser/useEventListener';
import type { WindowEventName } from '@/composables/browser/useEventListener';
const DEFAULT_EVENTS: WindowEventName[] = ['mousemove', 'mousedown', 'resize', 'keydown', 'touchstart', 'wheel'];
const ONE_MINUTE = 60_000;
export interface UseIdleOptions extends ConfigurableWindow, ConfigurableEventFilter {
/**
* Event names to listen to for detecting user activity
*
* @default ['mousemove', 'mousedown', 'resize', 'keydown', 'touchstart', 'wheel']
*/
events?: WindowEventName[];
/**
* Reset the idle timer when the document becomes visible again
*
* @default true
*/
listenForVisibilityChange?: boolean;
/**
* Initial value of the `idle` ref
*
* @default false
*/
initialState?: boolean;
}
export interface UseIdleReturn {
/**
* Whether the user is currently idle
*/
idle: ShallowRef<boolean>;
/**
* Timestamp (ms) of the last detected user activity
*/
lastActive: ShallowRef<number>;
/**
* Whether the idle tracker is currently running
*/
isPending: Readonly<ShallowRef<boolean>>;
/**
* Manually mark the user as active and restart the idle timer
*/
reset: () => void;
/**
* Begin (or resume) tracking. Restarts the idle timer unless `initialState` is `true`
*/
start: () => void;
/**
* Stop tracking. Resets `idle` to `initialState` and clears the pending timer
*/
stop: () => void;
}
/**
* @name useIdle
* @category Browser
* @description Track whether the user has been inactive for a given duration.
*
* @param {number} [timeout=60000] Idle threshold in milliseconds
* @param {UseIdleOptions} [options={}] Options
* @returns {UseIdleReturn} `{ idle, lastActive, isPending, reset, start, stop }`
*
* @example
* const { idle, lastActive, reset } = useIdle(5 * 60_000); // 5 minutes
*
* @example
* const { idle } = useIdle(10_000, { events: ['keydown'] });
*
* @since 0.0.15
*/
export function useIdle(
timeout: number = ONE_MINUTE,
options: UseIdleOptions = {},
): UseIdleReturn {
const {
initialState = false,
listenForVisibilityChange = true,
events = DEFAULT_EVENTS,
window = defaultWindow,
eventFilter = throttleFilter(50),
} = options;
const idle = shallowRef(initialState);
const lastActive = shallowRef(timestamp());
const isPending = shallowRef(false);
let timer: ReturnType<typeof setTimeout> | undefined;
const reset = (): void => {
idle.value = false;
if (timer !== undefined)
clearTimeout(timer);
timer = setTimeout(() => {
idle.value = true;
}, timeout);
};
const onEvent = createFilterWrapper(
eventFilter,
() => {
lastActive.value = timestamp();
reset();
},
);
const start = (): void => {
if (isPending.value)
return;
isPending.value = true;
if (!initialState)
reset();
};
const stop = (): void => {
idle.value = initialState;
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
isPending.value = false;
};
if (window) {
const document = window.document;
const listenerOptions = { passive: true };
for (const event of events) {
useEventListener(window, event, () => {
if (!isPending.value)
return;
onEvent();
}, listenerOptions);
}
if (listenForVisibilityChange) {
useEventListener(document, 'visibilitychange', () => {
if (document.hidden || !isPending.value)
return;
onEvent();
}, listenerOptions);
}
start();
}
return {
idle,
lastActive,
isPending: shallowReadonly(isPending),
reset,
start,
stop,
};
}
@@ -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;
}
@@ -1,206 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, ref } from 'vue';
import { useIntersectionObserver } from '.';
interface StubInstance {
cb: IntersectionObserverCallback;
options?: IntersectionObserverInit;
observe: ReturnType<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn>;
}
let instances: StubInstance[] = [];
class StubIntersectionObserver {
observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
takeRecords = vi.fn();
cb: IntersectionObserverCallback;
options?: IntersectionObserverInit;
constructor(cb: IntersectionObserverCallback, options?: IntersectionObserverInit) {
this.cb = cb;
this.options = options;
instances.push(this);
}
}
describe(useIntersectionObserver, () => {
beforeEach(() => {
instances = [];
vi.stubGlobal('IntersectionObserver', StubIntersectionObserver);
});
afterEach(() => vi.unstubAllGlobals());
it('observes the target immediately', () => {
const el = document.createElement('div');
const scope = effectScope();
scope.run(() => useIntersectionObserver(ref(el), vi.fn()));
expect(instances).toHaveLength(1);
expect(instances[0]!.observe).toHaveBeenCalledWith(el);
scope.stop();
});
it('does not observe when immediate is false', () => {
const el = document.createElement('div');
const scope = effectScope();
scope.run(() => useIntersectionObserver(ref(el), vi.fn(), { immediate: false }));
expect(instances).toHaveLength(0);
scope.stop();
});
it('pause disconnects and resume re-observes', async () => {
const el = document.createElement('div');
const scope = effectScope();
let controls: ReturnType<typeof useIntersectionObserver>;
scope.run(() => {
controls = useIntersectionObserver(ref(el), vi.fn());
});
controls!.pause();
expect(instances[0]!.disconnect).toHaveBeenCalled();
expect(controls!.isActive.value).toBeFalsy();
controls!.resume();
await nextTick();
expect(controls!.isActive.value).toBeTruthy();
expect(instances).toHaveLength(2);
scope.stop();
});
it('stop disconnects and marks inactive', () => {
const el = document.createElement('div');
const scope = effectScope();
let controls: ReturnType<typeof useIntersectionObserver>;
scope.run(() => {
controls = useIntersectionObserver(ref(el), vi.fn());
});
controls!.stop();
expect(instances[0]!.disconnect).toHaveBeenCalled();
expect(controls!.isActive.value).toBeFalsy();
scope.stop();
});
it('invokes the callback with entries', () => {
const el = document.createElement('div');
const callback = vi.fn();
const scope = effectScope();
scope.run(() => useIntersectionObserver(ref(el), callback));
const entry = { isIntersecting: true, time: 1 } as IntersectionObserverEntry;
instances[0]!.cb([entry], instances[0] as unknown as IntersectionObserver);
expect(callback).toHaveBeenCalled();
scope.stop();
});
it('observes an array of targets', () => {
const a = document.createElement('div');
const b = document.createElement('div');
const scope = effectScope();
scope.run(() => useIntersectionObserver([ref(a), ref(b)], vi.fn()));
expect(instances).toHaveLength(1);
expect(instances[0]!.observe).toHaveBeenCalledWith(a);
expect(instances[0]!.observe).toHaveBeenCalledWith(b);
scope.stop();
});
it('tracks a reactive target ref of an array', async () => {
const a = document.createElement('div');
const b = document.createElement('div');
const list = ref([a]);
const scope = effectScope();
scope.run(() => useIntersectionObserver(list, vi.fn()));
expect(instances).toHaveLength(1);
expect(instances[0]!.observe).toHaveBeenCalledTimes(1);
list.value = [a, b];
await nextTick();
// recreated with both elements
expect(instances).toHaveLength(2);
expect(instances[1]!.observe).toHaveBeenCalledWith(a);
expect(instances[1]!.observe).toHaveBeenCalledWith(b);
scope.stop();
});
it('tracks a getter target', async () => {
const a = document.createElement('div');
const enabled = ref(false);
const scope = effectScope();
scope.run(() => useIntersectionObserver(() => (enabled.value ? a : null), vi.fn()));
// null target -> no observer
expect(instances).toHaveLength(0);
enabled.value = true;
await nextTick();
expect(instances).toHaveLength(1);
expect(instances[0]!.observe).toHaveBeenCalledWith(a);
scope.stop();
});
it('passes rootMargin and threshold to the observer', () => {
const el = document.createElement('div');
const scope = effectScope();
scope.run(() => useIntersectionObserver(ref(el), vi.fn(), { rootMargin: '10px', threshold: [0, 0.5, 1] }));
expect(instances[0]!.options?.rootMargin).toBe('10px');
expect(instances[0]!.options?.threshold).toEqual([0, 0.5, 1]);
scope.stop();
});
it('reacts to a reactive rootMargin', async () => {
const el = document.createElement('div');
const rootMargin = ref('0px');
const scope = effectScope();
scope.run(() => useIntersectionObserver(ref(el), vi.fn(), { rootMargin }));
expect(instances[0]!.options?.rootMargin).toBe('0px');
rootMargin.value = '20px';
await nextTick();
expect(instances).toHaveLength(2);
expect(instances[1]!.options?.rootMargin).toBe('20px');
scope.stop();
});
it('reacts to a reactive threshold', async () => {
const el = document.createElement('div');
const threshold = ref<number | number[]>(0);
const scope = effectScope();
scope.run(() => useIntersectionObserver(ref(el), vi.fn(), { threshold }));
expect(instances[0]!.options?.threshold).toBe(0);
threshold.value = 0.75;
await nextTick();
expect(instances).toHaveLength(2);
expect(instances[1]!.options?.threshold).toBe(0.75);
scope.stop();
});
it('reports unsupported and never constructs an observer', () => {
// jsdom has no native IntersectionObserver; remove the stub so the
// feature detection `'IntersectionObserver' in window` reports false.
vi.unstubAllGlobals();
delete (globalThis as Record<string, unknown>).IntersectionObserver;
const el = document.createElement('div');
const scope = effectScope();
let controls: ReturnType<typeof useIntersectionObserver>;
scope.run(() => {
controls = useIntersectionObserver(ref(el), vi.fn());
});
expect(controls!.isSupported.value).toBeFalsy();
// stop should be a safe no-op
expect(() => controls!.stop()).not.toThrow();
scope.stop();
});
});
@@ -1,150 +0,0 @@
import { computed, readonly, ref, toValue, watch } from 'vue';
import type { MaybeRefOrGetter, Ref } from 'vue';
import { noop, toArray } from '@robonen/stdlib';
import type { ConfigurableWindow } from '@/types';
import { defaultWindow } from '@/types';
import type { MaybeComputedElementRef, MaybeElement } from '@/composables/component/unrefElement';
import { unrefElement } from '@/composables/component/unrefElement';
import { useSupported } from '@/composables/browser/useSupported';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
export interface UseIntersectionObserverOptions extends ConfigurableWindow {
/**
* The element or document used as the viewport for checking visibility
*/
root?: MaybeComputedElementRef | Document;
/**
* Margin around the root. Reactive — pass a ref or getter to update it.
*
* @default '0px'
*/
rootMargin?: MaybeRefOrGetter<string>;
/**
* Threshold(s) at which to trigger the callback. Reactive — pass a ref or
* getter to update it.
*
* @default 0
*/
threshold?: MaybeRefOrGetter<number | number[]>;
/**
* Start observing immediately
*
* @default true
*/
immediate?: boolean;
}
export interface UseIntersectionObserverReturn {
isSupported: Readonly<Ref<boolean>>;
isActive: Readonly<Ref<boolean>>;
pause: () => void;
resume: () => void;
stop: () => void;
}
/**
* @name useIntersectionObserver
* @category Browser
* @description Detect when an element enters or leaves the viewport via
* `IntersectionObserver`. Accepts a single target, an array of targets, or a
* ref/getter resolving to either, plus reactive `rootMargin` and `threshold`.
*
* @param {MaybeComputedElementRef | MaybeComputedElementRef[] | MaybeRefOrGetter<MaybeElement[]>} target Element(s) to observe
* @param {IntersectionObserverCallback} callback Invoked with the observer entries
* @param {UseIntersectionObserverOptions} [options={}] Options
* @returns {UseIntersectionObserverReturn} Observer controls
*
* @example
* useIntersectionObserver(el, ([{ isIntersecting }]) => {
* visible.value = isIntersecting;
* });
*
* @since 0.0.15
*/
export function useIntersectionObserver(
target: MaybeComputedElementRef | MaybeComputedElementRef[] | MaybeRefOrGetter<MaybeElement[]>,
callback: IntersectionObserverCallback,
options: UseIntersectionObserverOptions = {},
): UseIntersectionObserverReturn {
const {
root,
rootMargin = '0px',
threshold = 0,
window = defaultWindow,
immediate = true,
} = options;
const isSupported = useSupported(() => window && 'IntersectionObserver' in window);
const targets = computed(() => {
const value = toValue(target) as MaybeElement | MaybeElement[];
return toArray(value as MaybeElement)
.map(el => unrefElement(el))
.filter((el): el is Element => Boolean(el));
});
const isActive = ref(immediate);
let cleanup = noop;
const stopWatch = isSupported.value
? watch(
() => [
targets.value,
unrefElement(root as MaybeComputedElementRef),
toValue(rootMargin),
toValue(threshold),
isActive.value,
] as const,
([els, rootEl, margin, thresh, active]) => {
cleanup();
if (!active || !els.length)
return;
const observer = new IntersectionObserver(callback, {
root: (rootEl as Element | null) ?? (root as Document | undefined),
rootMargin: margin,
threshold: thresh,
});
for (const el of els)
observer.observe(el);
cleanup = () => {
observer.disconnect();
cleanup = noop;
};
},
{ immediate: true, flush: 'post' },
)
: noop;
const resume = (): void => {
isActive.value = true;
};
const pause = (): void => {
cleanup();
isActive.value = false;
};
const stop = (): void => {
cleanup();
stopWatch();
isActive.value = false;
};
tryOnScopeDispose(stop);
return {
isSupported,
isActive: readonly(isActive),
pause,
resume,
stop,
};
}
@@ -1,259 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { defineComponent, effectScope, nextTick, ref } from 'vue';
import { mount } from '@vue/test-utils';
import { useIntervalFn } from '.';
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
const ComponentStub = defineComponent({
props: {
callback: {
type: Function,
required: true,
},
interval: {
type: Number,
default: 1000,
},
options: {
type: Object,
default: () => ({}),
},
},
setup(props) {
const result = useIntervalFn(props.callback as () => void, props.interval, props.options);
return { ...result };
},
template: '<div>{{ isActive }}</div>',
});
describe(useIntervalFn, () => {
it('starts immediately by default', () => {
const callback = vi.fn();
const wrapper = mount(ComponentStub, {
props: { callback },
});
expect(wrapper.text()).toBe('true');
});
it('does not start when immediate is false', () => {
const callback = vi.fn();
mount(ComponentStub, {
props: {
callback,
options: { immediate: false },
},
});
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(5000);
expect(callback).not.toHaveBeenCalled();
});
it('calls callback on each interval', () => {
const callback = vi.fn();
mount(ComponentStub, {
props: { callback, interval: 500 },
});
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(500);
expect(callback).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(500);
expect(callback).toHaveBeenCalledTimes(2);
vi.advanceTimersByTime(1500);
expect(callback).toHaveBeenCalledTimes(5);
});
it('calls callback immediately when immediateCallback is true', () => {
const callback = vi.fn();
mount(ComponentStub, {
props: {
callback,
interval: 1000,
options: { immediateCallback: true },
},
});
expect(callback).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledTimes(2);
});
it('pauses and resumes', async () => {
const callback = vi.fn();
const wrapper = mount(ComponentStub, {
props: { callback, interval: 100 },
});
vi.advanceTimersByTime(300);
expect(callback).toHaveBeenCalledTimes(3);
wrapper.vm.pause();
await nextTick();
expect(wrapper.text()).toBe('false');
vi.advanceTimersByTime(500);
expect(callback).toHaveBeenCalledTimes(3);
wrapper.vm.resume();
await nextTick();
expect(wrapper.text()).toBe('true');
vi.advanceTimersByTime(200);
expect(callback).toHaveBeenCalledTimes(5);
});
it('toggles the interval', async () => {
const callback = vi.fn();
const wrapper = mount(ComponentStub, {
props: { callback },
});
expect(wrapper.text()).toBe('true');
wrapper.vm.toggle();
await nextTick();
expect(wrapper.text()).toBe('false');
wrapper.vm.toggle();
await nextTick();
expect(wrapper.text()).toBe('true');
});
it('supports reactive interval', async () => {
const callback = vi.fn();
const interval = ref(1000);
const scope = effectScope();
scope.run(() => {
useIntervalFn(callback, interval);
});
vi.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledTimes(1);
// Change interval to 200ms — watcher triggers async
interval.value = 200;
await nextTick();
vi.advanceTimersByTime(200);
expect(callback).toHaveBeenCalledTimes(2);
vi.advanceTimersByTime(200);
expect(callback).toHaveBeenCalledTimes(3);
scope.stop();
});
it('does not fire with interval <= 0', () => {
const callback = vi.fn();
const scope = effectScope();
scope.run(() => {
const { isActive } = useIntervalFn(callback, 0);
expect(isActive.value).toBeFalsy();
});
vi.advanceTimersByTime(5000);
expect(callback).not.toHaveBeenCalled();
scope.stop();
});
it('cleans up on scope dispose', () => {
const callback = vi.fn();
const scope = effectScope();
scope.run(() => {
useIntervalFn(callback, 100);
});
vi.advanceTimersByTime(300);
expect(callback).toHaveBeenCalledTimes(3);
scope.stop();
vi.advanceTimersByTime(500);
expect(callback).toHaveBeenCalledTimes(3);
});
it('cleans up on component unmount', () => {
const callback = vi.fn();
const wrapper = mount(ComponentStub, {
props: { callback, interval: 100 },
});
vi.advanceTimersByTime(300);
expect(callback).toHaveBeenCalledTimes(3);
wrapper.unmount();
vi.advanceTimersByTime(500);
expect(callback).toHaveBeenCalledTimes(3);
});
it('resume is idempotent when already active', () => {
const callback = vi.fn();
const scope = effectScope();
let result: ReturnType<typeof useIntervalFn>;
scope.run(() => {
result = useIntervalFn(callback, 100);
});
expect(result!.isActive.value).toBeTruthy();
result!.resume();
expect(result!.isActive.value).toBeTruthy();
// Should still tick normally — no double interval
vi.advanceTimersByTime(100);
expect(callback).toHaveBeenCalledTimes(1);
scope.stop();
});
it('pause is idempotent when already paused', () => {
const callback = vi.fn();
const scope = effectScope();
let result: ReturnType<typeof useIntervalFn>;
scope.run(() => {
result = useIntervalFn(callback, 100, { immediate: false });
});
expect(result!.isActive.value).toBeFalsy();
result!.pause();
expect(result!.isActive.value).toBeFalsy();
scope.stop();
});
it('uses default interval of 1000ms', () => {
const callback = vi.fn();
const scope = effectScope();
scope.run(() => {
useIntervalFn(callback);
});
vi.advanceTimersByTime(999);
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(callback).toHaveBeenCalledTimes(1);
scope.stop();
});
});
@@ -1,112 +0,0 @@
import { readonly, ref, toValue, watch } from 'vue';
import type { MaybeRefOrGetter, Ref } from 'vue';
import type { ResumableActions, ResumableOptions } from '@/types';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
export interface UseIntervalFnOptions extends ResumableOptions {
/**
* Whether to invoke the callback immediately on start.
*
* @default false
*/
immediateCallback?: boolean;
}
export interface UseIntervalFnReturn extends ResumableActions {
/**
* Whether the interval is currently active
*/
isActive: Readonly<Ref<boolean>>;
}
/**
* Call a function on every interval. Supports reactive interval duration,
* pause/resume, and automatic cleanup on scope dispose.
*
* @param callback - Function to call on every interval tick
* @param interval - Interval duration in milliseconds (can be reactive)
* @param options - Configuration options
*
* @example
* ```ts
* const { pause, resume, isActive } = useIntervalFn(() => {
* console.log('tick');
* }, 1000);
* ```
*
* @example
* ```ts
* // Reactive interval
* const delay = ref(1000);
* useIntervalFn(() => console.log('tick'), delay);
* delay.value = 500; // interval restarts with new duration
* ```
*/
export function useIntervalFn(
callback: () => void,
interval: MaybeRefOrGetter<number> = 1000,
options: UseIntervalFnOptions = {},
): UseIntervalFnReturn {
const {
immediate = true,
immediateCallback = false,
} = options;
const isActive = ref(false);
let timerId: ReturnType<typeof setInterval> | null = null;
function clean() {
if (timerId !== null) {
clearInterval(timerId);
timerId = null;
}
}
function resume() {
const ms = toValue(interval);
if (ms <= 0)
return;
isActive.value = true;
if (immediateCallback)
callback();
clean();
timerId = setInterval(callback, ms);
}
function pause() {
isActive.value = false;
clean();
}
function toggle() {
if (isActive.value)
pause();
else
resume();
}
// Re-start when interval changes reactively
watch(() => toValue(interval), () => {
if (isActive.value) {
clean();
resume();
}
});
if (immediate)
resume();
tryOnScopeDispose(pause);
return {
isActive: readonly(isActive),
pause,
resume,
toggle,
};
}
@@ -1,174 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick } from 'vue';
import { useKeyModifier } from '.';
import type { KeyModifier } from '.';
afterEach(() => {
vi.unstubAllGlobals();
});
/**
* Dispatch an event on `document` whose `getModifierState(modifier)` resolves to `active`.
* jsdom does not track real modifier state, so we stub the method per event.
*/
function dispatchModifier(
type: string,
active: boolean,
modifier: KeyModifier = 'Shift',
withGetModifierState = true,
) {
const event = new Event(type) as Event & {
getModifierState?: (key: string) => boolean;
};
if (withGetModifierState) {
event.getModifierState = (key: string) => (key === modifier ? active : false);
}
document.dispatchEvent(event);
}
describe(useKeyModifier, () => {
it('defaults to null until the first matching event', () => {
const scope = effectScope();
let state: ReturnType<typeof useKeyModifier>;
scope.run(() => {
state = useKeyModifier('Shift');
});
expect(state!.value).toBeNull();
scope.stop();
});
it('respects a provided initial value', () => {
const scope = effectScope();
let state: ReturnType<typeof useKeyModifier<boolean>>;
scope.run(() => {
state = useKeyModifier('Shift', { initial: false });
});
expect(state!.value).toBeFalsy();
scope.stop();
});
it('updates when a default event reports the modifier active', async () => {
const scope = effectScope();
let state: ReturnType<typeof useKeyModifier>;
scope.run(() => {
state = useKeyModifier('Shift');
});
dispatchModifier('keydown', true, 'Shift');
await nextTick();
expect(state!.value).toBeTruthy();
dispatchModifier('keyup', false, 'Shift');
await nextTick();
expect(state!.value).toBeFalsy();
scope.stop();
});
it('tracks each modifier independently', async () => {
const scope = effectScope();
let caps: ReturnType<typeof useKeyModifier>;
scope.run(() => {
caps = useKeyModifier('CapsLock');
});
dispatchModifier('keydown', true, 'CapsLock');
await nextTick();
expect(caps!.value).toBeTruthy();
// An event reporting a different modifier (Shift) must not flip CapsLock
dispatchModifier('keydown', true, 'Shift');
await nextTick();
expect(caps!.value).toBeFalsy();
scope.stop();
});
it('only listens on the configured events', async () => {
const scope = effectScope();
let state: ReturnType<typeof useKeyModifier>;
scope.run(() => {
state = useKeyModifier('Shift', { events: ['keydown'] });
});
dispatchModifier('keyup', true, 'Shift');
await nextTick();
expect(state!.value).toBeNull();
dispatchModifier('keydown', true, 'Shift');
await nextTick();
expect(state!.value).toBeTruthy();
scope.stop();
});
it('ignores events without getModifierState', async () => {
const scope = effectScope();
let state: ReturnType<typeof useKeyModifier>;
scope.run(() => {
state = useKeyModifier('Shift', { initial: false });
});
dispatchModifier('keydown', true, 'Shift', false);
await nextTick();
expect(state!.value).toBeFalsy();
scope.stop();
});
it('removes its listeners when the scope is disposed', async () => {
const scope = effectScope();
let state: ReturnType<typeof useKeyModifier>;
scope.run(() => {
state = useKeyModifier('Shift');
});
scope.stop();
dispatchModifier('keydown', true, 'Shift');
await nextTick();
expect(state!.value).toBeNull();
});
it('accepts a custom document and listens on it', async () => {
const listeners = new Map<string, Set<EventListener>>();
const fakeDocument = {
addEventListener(type: string, listener: EventListener) {
if (!listeners.has(type))
listeners.set(type, new Set());
listeners.get(type)!.add(listener);
},
removeEventListener(type: string, listener: EventListener) {
listeners.get(type)?.delete(listener);
},
} as unknown as Document;
const scope = effectScope();
let state: ReturnType<typeof useKeyModifier>;
scope.run(() => {
state = useKeyModifier('Meta', { document: fakeDocument });
});
const event = { getModifierState: (k: string) => k === 'Meta' } as unknown as KeyboardEvent;
listeners.get('keydown')!.forEach(fn => fn(event));
await nextTick();
expect(state!.value).toBeTruthy();
scope.stop();
});
it('is a no-op under SSR (no document)', () => {
const scope = effectScope();
let state: ReturnType<typeof useKeyModifier>;
scope.run(() => {
state = useKeyModifier('Shift', { document: undefined, initial: false });
});
expect(state!.value).toBeFalsy();
scope.stop();
});
});
@@ -1,83 +0,0 @@
import { shallowRef } from 'vue';
import type { ShallowRef } from 'vue';
import { isFunction } from '@robonen/stdlib';
import { defaultDocument } from '@/types';
import type { ConfigurableDocument } from '@/types';
import { useEventListener } from '@/composables/browser/useEventListener';
import type { DocumentEventName } from '@/composables/browser/useEventListener';
export type KeyModifier
= | 'Alt'
| 'AltGraph'
| 'CapsLock'
| 'Control'
| 'Fn'
| 'FnLock'
| 'Meta'
| 'NumLock'
| 'ScrollLock'
| 'Shift'
| 'Symbol'
| 'SymbolLock';
const DEFAULT_EVENTS: DocumentEventName[] = ['mousedown', 'mouseup', 'keydown', 'keyup'];
export interface UseKeyModifierOptions<Initial> extends ConfigurableDocument {
/**
* Event names that will prompt an update to the modifier state.
*
* @default ['mousedown', 'mouseup', 'keydown', 'keyup']
*/
events?: DocumentEventName[];
/**
* Initial value of the returned ref.
*
* @default null
*/
initial?: Initial;
}
export type UseKeyModifierReturn<Initial> = ShallowRef<Initial extends boolean ? boolean : boolean | null>;
/**
* @name useKeyModifier
* @category Browser
* @description Reactive state of a keyboard modifier (CapsLock, NumLock, Shift, Control, Alt, Meta, ...) tracked via `KeyboardEvent.getModifierState`.
*
* @param {KeyModifier} modifier The modifier key to observe
* @param {UseKeyModifierOptions} [options={}] Options (`events` to listen on, `initial` value, custom `document`)
* @returns {UseKeyModifierReturn} A shallow ref holding the current modifier state (`null` until the first matching event)
*
* @example
* const capsLock = useKeyModifier('CapsLock');
* watch(capsLock, (on) => {
* if (on) showCapsLockWarning();
* });
*
* @example
* const shift = useKeyModifier('Shift', { initial: false, events: ['keydown', 'keyup'] });
*
* @since 0.0.15
*/
export function useKeyModifier<Initial extends boolean | null = null>(
modifier: KeyModifier,
options: UseKeyModifierOptions<Initial> = {},
): UseKeyModifierReturn<Initial> {
const {
events = DEFAULT_EVENTS,
document = defaultDocument,
initial = null,
} = options;
const state = shallowRef(initial) as ShallowRef<boolean | null>;
if (document) {
useEventListener(document, events, (event: KeyboardEvent | MouseEvent) => {
if (isFunction(event.getModifierState))
state.value = event.getModifierState(modifier);
}, { passive: true });
}
return state as UseKeyModifierReturn<Initial>;
}
@@ -0,0 +1,151 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { effectScope } from 'vue';
import { useLocalFonts } from '.';
import type { FontData, QueryLocalFontsOptions } from '.';
function makeFont(overrides: Partial<FontData> = {}): FontData {
return {
postscriptName: 'Arial-BoldMT',
fullName: 'Arial Bold',
family: 'Arial',
style: 'Bold',
blob: async () => new Blob(),
...overrides,
};
}
/**
* Build a fake `window` exposing `queryLocalFonts`. Passed through options so it
* reaches the import-time-captured `defaultWindow` substitute (see test gotcha).
*/
function createWindow(fonts: FontData[] = [makeFont()]) {
const queryLocalFonts = vi.fn(async (_options?: QueryLocalFontsOptions) => fonts);
const win = { queryLocalFonts } as unknown as Window & typeof globalThis;
return { window: win, queryLocalFonts };
}
describe(useLocalFonts, () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('reports supported when queryLocalFonts exists on window', () => {
const scope = effectScope();
const { window } = createWindow();
let result: ReturnType<typeof useLocalFonts>;
scope.run(() => {
result = useLocalFonts({ 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 useLocalFonts>;
scope.run(() => {
result = useLocalFonts({ 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 useLocalFonts>;
scope.run(() => {
result = useLocalFonts({ window: undefined as unknown as Window });
});
expect(result!.isSupported.value).toBeFalsy();
await expect(result!.query()).resolves.toBeUndefined();
scope.stop();
});
it('defaults fonts to an empty array', () => {
const scope = effectScope();
const { window } = createWindow();
let result: ReturnType<typeof useLocalFonts>;
scope.run(() => {
result = useLocalFonts({ window });
});
expect(result!.fonts.value).toEqual([]);
scope.stop();
});
it('populates fonts and returns the list on a successful query', async () => {
const scope = effectScope();
const fonts = [makeFont(), makeFont({ postscriptName: 'Times', fullName: 'Times New Roman' })];
const { window, queryLocalFonts } = createWindow(fonts);
let result: ReturnType<typeof useLocalFonts>;
scope.run(() => {
result = useLocalFonts({ window });
});
const returned = await result!.query();
expect(queryLocalFonts).toHaveBeenCalledTimes(1);
expect(returned).toEqual(fonts);
expect(result!.fonts.value).toEqual(fonts);
scope.stop();
});
it('forwards query options to the native API', async () => {
const scope = effectScope();
const { window, queryLocalFonts } = createWindow();
let result: ReturnType<typeof useLocalFonts>;
scope.run(() => {
result = useLocalFonts({ window });
});
await result!.query({ postscriptNames: ['Arial-BoldMT'] });
expect(queryLocalFonts).toHaveBeenCalledWith({ postscriptNames: ['Arial-BoldMT'] });
scope.stop();
});
it('stores the error and invokes onError when the query rejects', async () => {
const scope = effectScope();
const error = new DOMException('denied', 'NotAllowedError');
const queryLocalFonts = vi.fn(async () => {
throw error;
});
const win = { queryLocalFonts } as unknown as Window & typeof globalThis;
const onError = vi.fn();
let result: ReturnType<typeof useLocalFonts>;
scope.run(() => {
result = useLocalFonts({ window: win, onError });
});
await expect(result!.query()).resolves.toBeUndefined();
expect(onError).toHaveBeenCalledWith(error);
expect(result!.error.value).toBe(error);
expect(result!.fonts.value).toEqual([]);
scope.stop();
});
it('returns undefined and does not call the API when unsupported', async () => {
const scope = effectScope();
const win = {} as unknown as Window & typeof globalThis;
let result: ReturnType<typeof useLocalFonts>;
scope.run(() => {
result = useLocalFonts({ window: win });
});
await expect(result!.query()).resolves.toBeUndefined();
scope.stop();
});
});
@@ -0,0 +1,158 @@
import { 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 { tryOnMounted } from '@/composables/lifecycle/tryOnMounted';
/**
* A single font face exposed by the [Local Font Access API](https://developer.mozilla.org/en-US/docs/Web/API/FontData).
*/
export interface FontData {
/**
* The PostScript name of the font (e.g. `"Arial-BoldMT"`)
*/
readonly postscriptName: string;
/**
* The full, human-readable name of the font (e.g. `"Arial Bold"`)
*/
readonly fullName: string;
/**
* The font family (e.g. `"Arial"`)
*/
readonly family: string;
/**
* The font style/subfamily (e.g. `"Bold"`)
*/
readonly style: string;
/**
* Resolve the raw font file as a `Blob` (the full SFNT binary)
*/
blob: () => Promise<Blob>;
}
export interface QueryLocalFontsOptions {
/**
* Restrict the results to the fonts whose PostScript names appear in this list.
*/
postscriptNames?: string[];
}
interface WindowWithLocalFonts {
queryLocalFonts: (options?: QueryLocalFontsOptions) => Promise<FontData[]>;
}
export interface UseLocalFontsOptions extends ConfigurableWindow {
/**
* Query the local fonts immediately on mount. The Local Font Access API
* requires the `local-fonts` permission (which may prompt the user), so this
* is disabled by default — call `query()` from a user gesture instead.
*
* @default false
*/
immediate?: boolean;
/**
* Called when a query rejects (e.g. the permission is denied) instead of
* throwing. The same value is also stored in the returned `error` ref.
*
* @default noop
*/
onError?: (error: unknown) => void;
}
export interface UseLocalFontsReturn {
/**
* Whether the [Local Font Access API](https://developer.mozilla.org/en-US/docs/Web/API/Local_Font_Access_API) is supported
*/
isSupported: ComputedRef<boolean>;
/**
* The fonts returned by the most recent successful `query()`
*/
fonts: ShallowRef<FontData[]>;
/**
* The last error thrown by `query()`, or `null`
*/
error: ShallowRef<unknown | null>;
/**
* Enumerate the locally installed fonts. Resolves with the font list, or
* `undefined` when the API is unsupported. Pass `postscriptNames` to filter.
*/
query: (queryOptions?: QueryLocalFontsOptions) => Promise<FontData[] | undefined>;
}
/**
* @name useLocalFonts
* @category Browser
* @description Reactive wrapper around the [Local Font Access API](https://developer.mozilla.org/en-US/docs/Web/API/Local_Font_Access_API) for enumerating the user's locally installed fonts.
*
* @param {UseLocalFontsOptions} [options={}] Options
* @param {boolean} [options.immediate=false] Query immediately on mount (requires the `local-fonts` permission)
* @param {Function} [options.onError=noop] Error callback invoked instead of throwing
* @param {Window} [options.window=defaultWindow] Custom `window` instance
* @returns {UseLocalFontsReturn} `isSupported`, `fonts`, `error`, and `query()`
*
* @example
* const { isSupported, fonts, query } = useLocalFonts();
* // Call from a click handler so the permission prompt is allowed
* async function pickFonts() {
* await query();
* console.log(fonts.value.map(font => font.fullName));
* }
*
* @example
* // Query only specific fonts by PostScript name
* const { fonts, query } = useLocalFonts();
* await query({ postscriptNames: ['Arial-BoldMT'] });
*
* @since 0.0.15
*/
export function useLocalFonts(options: UseLocalFontsOptions = {}): UseLocalFontsReturn {
const {
window = defaultWindow,
immediate = false,
onError = noop,
} = options;
const isSupported = useSupported(() => !!window && 'queryLocalFonts' in window);
const fonts = shallowRef<FontData[]>([]);
const error = shallowRef<unknown | null>(null);
async function query(queryOptions?: QueryLocalFontsOptions): Promise<FontData[] | undefined> {
if (!isSupported.value || !window)
return undefined;
error.value = null;
try {
const result = await (window as unknown as WindowWithLocalFonts).queryLocalFonts(queryOptions);
fonts.value = result;
return result;
}
catch (err) {
error.value = err;
onError(err);
return undefined;
}
}
if (immediate)
tryOnMounted(() => query());
return {
isSupported,
fonts,
error,
query,
};
}
@@ -1,305 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, toValue, watch } from 'vue';
import { DefaultMagicKeysAliasMap, useMagicKeys } from '.';
function keydown(key: string, init: KeyboardEventInit = {}, target: EventTarget = globalThis) {
target.dispatchEvent(new KeyboardEvent('keydown', { key, ...init }));
}
function keyup(key: string, init: KeyboardEventInit = {}, target: EventTarget = globalThis) {
target.dispatchEvent(new KeyboardEvent('keyup', { key, ...init }));
}
describe(useMagicKeys, () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('tracks a single pressed key', () => {
const scope = effectScope();
scope.run(() => {
const keys = useMagicKeys();
expect(keys.a!.value).toBeFalsy();
keydown('a');
expect(keys.a!.value).toBeTruthy();
keyup('a');
expect(keys.a!.value).toBeFalsy();
});
scope.stop();
});
it('exposes the current Set of pressed keys', () => {
const scope = effectScope();
scope.run(() => {
const keys = useMagicKeys();
keydown('a');
keydown('b');
expect([...keys.current]).toEqual(['a', 'b']);
keyup('a');
expect([...keys.current]).toEqual(['b']);
});
scope.stop();
});
it('supports combinations via proxy property (ctrl+a)', () => {
const scope = effectScope();
scope.run(() => {
const keys = useMagicKeys();
const ctrlA = keys['ctrl+a']!;
expect(ctrlA.value).toBeFalsy();
keydown('Control');
expect(ctrlA.value).toBeFalsy();
keydown('a');
expect(ctrlA.value).toBeTruthy();
keyup('a');
expect(ctrlA.value).toBeFalsy();
});
scope.stop();
});
it('supports combos with _ and - delimiters', () => {
const scope = effectScope();
scope.run(() => {
const keys = useMagicKeys();
const underscore = keys.ctrl_a!;
const dash = keys['ctrl-a']!;
keydown('Control');
keydown('a');
expect(underscore.value).toBeTruthy();
expect(dash.value).toBeTruthy();
});
scope.stop();
});
it('resolves aliases (cmd -> meta, esc -> escape)', () => {
const scope = effectScope();
scope.run(() => {
const keys = useMagicKeys();
keydown('Meta');
expect(keys.cmd!.value).toBeTruthy();
expect(keys.command!.value).toBeTruthy();
expect(keys.meta!.value).toBeTruthy();
keyup('Meta');
keydown('Escape');
expect(keys.esc!.value).toBeTruthy();
});
scope.stop();
});
it('respects a custom aliasMap', () => {
const scope = effectScope();
scope.run(() => {
const keys = useMagicKeys({ aliasMap: { fire: 'f' } });
keydown('f');
expect(keys.fire!.value).toBeTruthy();
});
scope.stop();
});
it('is case-insensitive on property access', () => {
const scope = effectScope();
scope.run(() => {
const keys = useMagicKeys();
keydown('a');
expect(keys.A!.value).toBeTruthy();
});
scope.stop();
});
it('tracks event.code as well as key', () => {
const scope = effectScope();
scope.run(() => {
const keys = useMagicKeys();
keydown('a', { code: 'KeyA' });
expect((keys as any).keya.value).toBeTruthy();
keyup('a', { code: 'KeyA' });
expect((keys as any).keya.value).toBeFalsy();
});
scope.stop();
});
it('reactive mode returns plain booleans', () => {
const scope = effectScope();
scope.run(() => {
const keys = useMagicKeys({ reactive: true });
expect(keys.a).toBeFalsy();
keydown('a');
expect(keys.a).toBeTruthy();
});
scope.stop();
});
it('reset() clears the current Set and all refs', () => {
const scope = effectScope();
scope.run(() => {
const keys = useMagicKeys();
keydown('a');
keydown('b');
expect(keys.a!.value).toBeTruthy();
keys.reset();
expect(keys.a!.value).toBeFalsy();
expect(keys.b!.value).toBeFalsy();
expect(keys.current.size).toBe(0);
});
scope.stop();
});
it('resets on blur so keys do not stick', () => {
const scope = effectScope();
scope.run(() => {
const keys = useMagicKeys();
keydown('a');
expect(keys.a!.value).toBeTruthy();
globalThis.dispatchEvent(new Event('blur'));
expect(keys.a!.value).toBeFalsy();
expect(keys.current.size).toBe(0);
});
scope.stop();
});
it('clears meta-dependent keys when Meta is released (macOS behaviour)', () => {
const scope = effectScope();
scope.run(() => {
const keys = useMagicKeys();
// Press Cmd, then 'a' while Cmd held (getModifierState reports Meta)
keydown('Meta');
keydown('a', { metaKey: true });
expect(keys.a!.value).toBeTruthy();
expect(keys.current.has('a')).toBeTruthy();
// Releasing Meta should drop 'a' too (no keyup fires for it on macOS)
keyup('Meta');
expect(keys.meta!.value).toBeFalsy();
expect(keys.a!.value).toBeFalsy();
expect(keys.current.has('a')).toBeFalsy();
});
scope.stop();
});
it('invokes onEventFired for keydown and keyup', () => {
const scope = effectScope();
scope.run(() => {
const onEventFired = vi.fn();
useMagicKeys({ onEventFired });
keydown('a');
keyup('a');
expect(onEventFired).toHaveBeenCalledTimes(2);
expect(onEventFired.mock.calls[0]![0]).toBeInstanceOf(KeyboardEvent);
});
scope.stop();
});
it('attaches to a custom target', () => {
const scope = effectScope();
scope.run(() => {
const el = document.createElement('div');
const keys = useMagicKeys({ target: el });
keydown('a', {}, el);
expect(keys.a!.value).toBeTruthy();
// window events should not affect a custom target
keyup('a', {}, el);
keydown('b');
expect(keys.b!.value).toBeFalsy();
});
scope.stop();
});
it('combination refs are reactive (computed)', async () => {
const scope = effectScope();
await scope.run(async () => {
const keys = useMagicKeys();
const combo = keys['shift+a']!;
const seen: boolean[] = [];
watch(combo, v => seen.push(v));
keydown('Shift');
keydown('a');
await nextTick();
expect(toValue(combo)).toBeTruthy();
expect(seen).toContain(true);
});
scope.stop();
});
it('exposes DefaultMagicKeysAliasMap with expected aliases', () => {
expect(DefaultMagicKeysAliasMap.ctrl).toBe('control');
expect(DefaultMagicKeysAliasMap.cmd).toBe('meta');
expect(DefaultMagicKeysAliasMap.command).toBe('meta');
expect(DefaultMagicKeysAliasMap.option).toBe('alt');
expect(DefaultMagicKeysAliasMap.up).toBe('arrowup');
});
it('does not throw and returns an object when window is absent (SSR path)', () => {
// No target available -> listeners are skipped, refs still usable
const keys = useMagicKeys({ target: undefined as unknown as EventTarget });
expect(keys.current.size).toBe(0);
// accessing a key lazily creates a ref defaulting to false
expect(keys.a!.value).toBeFalsy();
});
});
@@ -1,227 +0,0 @@
import type { AnyFunction } from '@robonen/stdlib';
import { isFunction, noop } from '@robonen/stdlib';
import type { ComputedRef, Ref, ShallowRef } from 'vue';
import { computed, reactive, shallowRef, toValue } from 'vue';
import { useEventListener } from '@/composables/browser/useEventListener';
import { defaultWindow } from '@/types';
export type UseMagicKeysAliasMap = Readonly<Record<string, string>>;
/**
* Default lowercase alias map: maps common shorthand key names to their
* canonical `KeyboardEvent.key` (lowercased) equivalents.
*/
export const DefaultMagicKeysAliasMap: UseMagicKeysAliasMap = /* #__PURE__ */ {
ctrl: 'control',
command: 'meta',
cmd: 'meta',
option: 'alt',
opt: 'alt',
up: 'arrowup',
down: 'arrowdown',
left: 'arrowleft',
right: 'arrowright',
esc: 'escape',
space: ' ',
};
export interface UseMagicKeysOptions<Reactive extends boolean> {
/**
* Return a reactive object instead of an object of refs
*
* @default false
*/
reactive?: Reactive;
/**
* Event target to attach the keyboard listeners to
*
* @default window
*/
target?: EventTarget;
/**
* Alias map for keys, all keys should be lowercase
* { foo: 'bar' } means that pressing `bar` will also trigger `foo`
*
* @default DefaultMagicKeysAliasMap
*/
aliasMap?: UseMagicKeysAliasMap;
/**
* Register passive listeners
*
* @default true
*/
passive?: boolean;
/**
* Custom event handler for the keyboard event. Useful for preventing default
* behaviour for certain key combos. Called on every keydown and keyup.
*/
onEventFired?: (event: KeyboardEvent) => void | boolean;
}
export interface UseMagicKeysReturn {
/**
* A Set of currently pressed keys (lowercase canonical names)
*/
current: Set<string>;
/**
* Reset all tracked keys to `false` and clear the current Set
*/
reset: () => void;
}
export type MagicKeys<Reactive extends boolean> = Readonly<
Omit<
Record<string, Reactive extends true ? boolean : ComputedRef<boolean>>,
keyof UseMagicKeysReturn
>
& UseMagicKeysReturn
>;
type KeyRefs = Record<string, Ref<boolean> | ShallowRef<boolean> | ComputedRef<boolean>>;
/**
* @name useMagicKeys
* @category Browser
* @description Reactive keys pressed state, with magical combination keys support via a Proxy.
* Access combinations directly as properties, e.g. `keys['ctrl+a']` or `keys.ctrl_a`.
*
* @param {UseMagicKeysOptions} [options] Configuration options
* @returns {MagicKeys} A Proxy of refs (or reactive booleans) plus `current` Set and `reset`
*
* @example
* const keys = useMagicKeys();
* const ctrlA = keys['ctrl+a'];
* watch(ctrlA, v => { if (v) console.log('Ctrl + A pressed'); });
*
* @example
* const { ctrl, a, current } = useMagicKeys({ reactive: true });
*
* @since 0.0.15
*/
export function useMagicKeys(options?: UseMagicKeysOptions<false>): MagicKeys<false>;
export function useMagicKeys(options: UseMagicKeysOptions<true>): MagicKeys<true>;
export function useMagicKeys(options: UseMagicKeysOptions<boolean> = {}): any {
const {
reactive: useReactive = false,
target = defaultWindow,
aliasMap = DefaultMagicKeysAliasMap,
passive = true,
onEventFired = noop as AnyFunction,
} = options;
const current = reactive(new Set<string>());
const usedKeys = new Set<string>();
// Keys pressed while Meta is held — on macOS, keyup is suppressed for other
// keys while Cmd is down, so we clear them when Meta is released.
const metaDeps = new Set<string>();
function reset(): void {
current.clear();
for (const key of usedKeys)
setRefs(key, false);
}
const obj: UseMagicKeysReturn = {
current,
reset,
};
const refs: KeyRefs = useReactive ? reactive(obj as any) : (obj as any);
function setRefs(key: string, value: boolean): void {
// Touch the proxy so the ref is materialized for keys we actually track,
// even if the consumer hasn't accessed them yet.
if (!(key in refs))
void (proxy as any)[key];
if (key in refs) {
if (useReactive)
(refs as any)[key] = value;
else
(refs[key] as Ref<boolean>).value = value;
}
}
function updateRefs(event: KeyboardEvent, value: boolean): void {
const key = event.key?.toLowerCase();
const code = event.code?.toLowerCase();
const values = [code, key].filter(Boolean) as string[];
if (!key)
return;
if (value)
current.add(key);
else
current.delete(key);
for (const k of values) {
usedKeys.add(k);
setRefs(k, value);
}
if (key === 'meta' && !value) {
// Cmd released on macOS: clear keys that were pressed during the chord
metaDeps.forEach((k) => {
current.delete(k);
setRefs(k, false);
});
metaDeps.clear();
}
else if (isFunction(event.getModifierState) && event.getModifierState('Meta') && value) {
[...current, ...values].forEach(k => metaDeps.add(k));
}
}
if (target) {
useEventListener(target, 'keydown', (event: KeyboardEvent) => {
updateRefs(event, true);
return onEventFired(event);
}, { passive });
useEventListener(target, 'keyup', (event: KeyboardEvent) => {
updateRefs(event, false);
return onEventFired(event);
}, { passive });
// Reset on blur so keys don't "stick" when focus leaves the page
useEventListener('blur', reset, { passive: true });
useEventListener('focus', reset, { passive: true });
}
const proxy = new Proxy(refs, {
get(target, prop, receiver) {
if (typeof prop !== 'string')
return Reflect.get(target, prop, receiver);
prop = prop.toLowerCase();
// alias resolution
if (prop in aliasMap)
prop = aliasMap[prop] as string;
// lazily create tracking ref for combos and single keys
if (!(prop in refs)) {
if (/[+_-]/.test(prop)) {
const keys = prop.split(/[+_-]/g).map((i: string) => i.trim());
refs[prop] = computed(() => keys.map(key => toValue((proxy as any)[key])).every(Boolean));
}
else {
refs[prop] = shallowRef(false);
}
}
const r = Reflect.get(target, prop, receiver);
return useReactive ? toValue(r) : r;
},
});
return proxy as any;
}
@@ -3,7 +3,7 @@ import type { ComputedRef, MaybeRefOrGetter } from 'vue';
import { isFunction, isNumber } from '@robonen/stdlib';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { useSupported } from '@/composables/browser/useSupported';
import { useSupported } from '@/composables/utilities/useSupported';
import { useEventListener } from '@/composables/browser/useEventListener';
export interface UseMediaQueryOptions extends ConfigurableWindow {
@@ -1,287 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, shallowRef } from 'vue';
import { useMouse } from '.';
function dispatchMouseMove(target: EventTarget, coords: Partial<Record<'pageX' | 'pageY' | 'clientX' | 'clientY' | 'screenX' | 'screenY' | 'movementX' | 'movementY', number>>) {
const event = new MouseEvent('mousemove');
for (const [key, value] of Object.entries(coords))
Object.defineProperty(event, key, { value, configurable: true });
target.dispatchEvent(event);
return event;
}
describe(useMouse, () => {
it('uses the initial value', () => {
const scope = effectScope();
let mouse: ReturnType<typeof useMouse>;
scope.run(() => {
mouse = useMouse({ initialValue: { x: 5, y: 10 } });
});
expect(mouse!.x.value).toBe(5);
expect(mouse!.y.value).toBe(10);
scope.stop();
});
it('tracks mousemove with page coordinates', async () => {
const scope = effectScope();
let mouse: ReturnType<typeof useMouse>;
scope.run(() => {
mouse = useMouse();
});
dispatchMouseMove(globalThis, { pageX: 100, pageY: 200 });
await nextTick();
expect(mouse!.x.value).toBe(100);
expect(mouse!.y.value).toBe(200);
expect(mouse!.sourceType.value).toBe('mouse');
scope.stop();
});
it('supports client coordinate type', async () => {
const scope = effectScope();
let mouse: ReturnType<typeof useMouse>;
scope.run(() => {
mouse = useMouse({ type: 'client' });
});
const event = new MouseEvent('mousemove', { clientX: 7, clientY: 9 });
globalThis.dispatchEvent(event);
await nextTick();
expect(mouse!.x.value).toBe(7);
expect(mouse!.y.value).toBe(9);
scope.stop();
});
it('supports screen coordinate type', async () => {
const scope = effectScope();
let mouse: ReturnType<typeof useMouse>;
scope.run(() => {
mouse = useMouse({ type: 'screen' });
});
const event = new MouseEvent('mousemove', { screenX: 11, screenY: 22 });
globalThis.dispatchEvent(event);
await nextTick();
expect(mouse!.x.value).toBe(11);
expect(mouse!.y.value).toBe(22);
scope.stop();
});
it('supports a custom extractor function', async () => {
const scope = effectScope();
let mouse: ReturnType<typeof useMouse>;
scope.run(() => {
mouse = useMouse({ type: e => [(e as MouseEvent).pageX * 2, (e as MouseEvent).pageY * 2] });
});
dispatchMouseMove(globalThis, { pageX: 3, pageY: 4 });
await nextTick();
expect(mouse!.x.value).toBe(6);
expect(mouse!.y.value).toBe(8);
scope.stop();
});
it('does not update when the extractor returns null', async () => {
const scope = effectScope();
let mouse: ReturnType<typeof useMouse>;
// a custom extractor that opts out of updating
scope.run(() => {
mouse = useMouse({ type: () => null, initialValue: { x: 1, y: 2 } });
});
dispatchMouseMove(globalThis, { pageX: 99, pageY: 99 });
await nextTick();
expect(mouse!.x.value).toBe(1);
expect(mouse!.y.value).toBe(2);
expect(mouse!.sourceType.value).toBe(null);
scope.stop();
});
it('tracks touch events', async () => {
const scope = effectScope();
let mouse: ReturnType<typeof useMouse>;
scope.run(() => {
mouse = useMouse();
});
const touch = { clientX: 0, clientY: 0, pageX: 50, pageY: 60 } as Touch;
const event = new Event('touchstart') as TouchEvent;
Object.defineProperty(event, 'touches', { value: [touch], configurable: true });
globalThis.dispatchEvent(event);
await nextTick();
expect(mouse!.x.value).toBe(50);
expect(mouse!.y.value).toBe(60);
expect(mouse!.sourceType.value).toBe('touch');
scope.stop();
});
it('ignores touch events when touch is disabled', async () => {
const scope = effectScope();
let mouse: ReturnType<typeof useMouse>;
scope.run(() => {
mouse = useMouse({ touch: false, initialValue: { x: 1, y: 1 } });
});
const touch = { pageX: 50, pageY: 60 } as Touch;
const event = new Event('touchstart') as TouchEvent;
Object.defineProperty(event, 'touches', { value: [touch], configurable: true });
globalThis.dispatchEvent(event);
await nextTick();
expect(mouse!.x.value).toBe(1);
expect(mouse!.y.value).toBe(1);
scope.stop();
});
it('resets on touchend when resetOnTouchEnds is set', async () => {
const scope = effectScope();
let mouse: ReturnType<typeof useMouse>;
scope.run(() => {
mouse = useMouse({ resetOnTouchEnds: true, initialValue: { x: 9, y: 9 } });
});
const touch = { pageX: 50, pageY: 60 } as Touch;
const start = new Event('touchstart') as TouchEvent;
Object.defineProperty(start, 'touches', { value: [touch], configurable: true });
globalThis.dispatchEvent(start);
await nextTick();
expect(mouse!.x.value).toBe(50);
globalThis.dispatchEvent(new Event('touchend'));
await nextTick();
expect(mouse!.x.value).toBe(9);
expect(mouse!.y.value).toBe(9);
scope.stop();
});
it('updates page coordinates on scroll without pointer movement', async () => {
const scope = effectScope();
let mouse: ReturnType<typeof useMouse>;
scope.run(() => {
mouse = useMouse({ type: 'page' });
});
// record a mouse position while scrollX/scrollY are 0
(globalThis as any).scrollX = 0;
(globalThis as any).scrollY = 0;
dispatchMouseMove(globalThis, { pageX: 100, pageY: 100 });
await nextTick();
expect(mouse!.x.value).toBe(100);
// scroll the page; page coordinates should shift by the scroll delta
(globalThis as any).scrollX = 30;
(globalThis as any).scrollY = 40;
globalThis.dispatchEvent(new Event('scroll'));
await nextTick();
expect(mouse!.x.value).toBe(130);
expect(mouse!.y.value).toBe(140);
(globalThis as any).scrollX = 0;
(globalThis as any).scrollY = 0;
scope.stop();
});
it('does not register a scroll listener for non-page types', async () => {
const addSpy = vi.spyOn(globalThis, 'addEventListener');
const scope = effectScope();
scope.run(() => {
useMouse({ type: 'client' });
});
const scrollCalls = addSpy.mock.calls.filter(([name]) => name === 'scroll');
expect(scrollCalls).toHaveLength(0);
addSpy.mockRestore();
scope.stop();
});
it('attaches passive listeners', () => {
const addSpy = vi.spyOn(globalThis, 'addEventListener');
const scope = effectScope();
scope.run(() => {
useMouse();
});
const moveCall = addSpy.mock.calls.find(([name]) => name === 'mousemove');
expect(moveCall).toBeDefined();
expect((moveCall![2] as AddEventListenerOptions).passive).toBeTruthy();
addSpy.mockRestore();
scope.stop();
});
it('listens on a custom element target', async () => {
const el = document.createElement('div');
const elRef = shallowRef(el);
const addSpy = vi.spyOn(el, 'addEventListener');
const scope = effectScope();
let mouse: ReturnType<typeof useMouse>;
scope.run(() => {
mouse = useMouse({ target: elRef, type: 'client' });
});
await nextTick();
expect(addSpy.mock.calls.some(([name]) => name === 'mousemove')).toBeTruthy();
const event = new MouseEvent('mousemove', { clientX: 5, clientY: 6 });
el.dispatchEvent(event);
await nextTick();
expect(mouse!.x.value).toBe(5);
expect(mouse!.y.value).toBe(6);
addSpy.mockRestore();
scope.stop();
});
it('does nothing when window is unavailable (SSR)', () => {
const scope = effectScope();
let mouse: ReturnType<typeof useMouse>;
scope.run(() => {
mouse = useMouse({ window: undefined, target: undefined });
});
expect(mouse!.x.value).toBe(0);
expect(mouse!.y.value).toBe(0);
expect(mouse!.sourceType.value).toBe(null);
scope.stop();
});
it('applies an event filter (throttle drops intermediate moves)', async () => {
vi.useFakeTimers();
const filtered = vi.fn();
// simple leading-only filter: only the first invoke passes immediately
let used = false;
const onceFilter = (invoke: () => void) => {
if (!used) {
used = true;
invoke();
}
};
const scope = effectScope();
let mouse: ReturnType<typeof useMouse>;
scope.run(() => {
mouse = useMouse({ eventFilter: onceFilter });
});
dispatchMouseMove(globalThis, { pageX: 10, pageY: 10 });
dispatchMouseMove(globalThis, { pageX: 20, pageY: 20 });
await nextTick();
// only the first move was let through
expect(mouse!.x.value).toBe(10);
expect(mouse!.y.value).toBe(10);
filtered();
scope.stop();
vi.useRealTimers();
});
});
@@ -1,204 +0,0 @@
import { shallowRef } from 'vue';
import type { Ref } from 'vue';
import { isFunction } from '@robonen/stdlib';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { useEventListener } from '@/composables/browser/useEventListener';
import { unrefElement } from '@/composables/component/unrefElement';
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
import { bypassFilter, createFilterWrapper } from '@/utils/filters';
import type { ConfigurableEventFilter } from '@/utils/filters';
export type UseMouseCoordType = 'page' | 'client' | 'screen' | 'movement';
export type UseMouseSourceType = 'mouse' | 'touch' | null;
/**
* Extracts an `[x, y]` pair from a mouse or touch point, or `null` to skip.
*/
export type UseMouseEventExtractor = (event: MouseEvent | Touch) => [x: number, y: number] | null | undefined;
export interface UseMouseOptions extends ConfigurableWindow, ConfigurableEventFilter {
/**
* Which coordinate pair to read from the event, or a custom extractor function
*
* @default 'page'
*/
type?: UseMouseCoordType | UseMouseEventExtractor;
/**
* Target to attach the listeners to. Accepts a window, document, element ref,
* getter, or component instance.
*
* @default window
*/
target?: MaybeComputedElementRef | Window | Document;
/**
* Listen to touch events
*
* @default true
*/
touch?: boolean;
/**
* Track window scroll so that `page` coordinates stay accurate while the page
* scrolls without the pointer moving. Only applies when `type === 'page'`.
*
* @default true
*/
scroll?: boolean;
/**
* Reset coordinates to `initialValue` on `touchend`
*
* @default false
*/
resetOnTouchEnds?: boolean;
/**
* Initial coordinates
*
* @default { x: 0, y: 0 }
*/
initialValue?: { x: number; y: number };
}
export interface UseMouseReturn {
x: Ref<number>;
y: Ref<number>;
sourceType: Ref<UseMouseSourceType>;
}
const builtinExtractors: Record<UseMouseCoordType, UseMouseEventExtractor> = {
page: e => [(e as MouseEvent).pageX, (e as MouseEvent).pageY],
client: e => [e.clientX, e.clientY],
screen: e => [e.screenX, e.screenY],
movement: e => ('movementX' in e ? [e.movementX, e.movementY] : null),
};
/**
* @name useMouse
* @category Browser
* @description Reactive mouse (and optionally touch) position with optional
* custom target, scroll tracking, custom extractors, and event filtering.
*
* @param {UseMouseOptions} [options={}] Options
* @returns {UseMouseReturn} Reactive `x`, `y`, and `sourceType`
*
* @example
* const { x, y, sourceType } = useMouse();
*
* @example
* // Track relative to an element, throttled
* const { x, y } = useMouse({ target: el, eventFilter: throttleFilter(50) });
*
* @since 0.0.15
*/
export function useMouse(options: UseMouseOptions = {}): UseMouseReturn {
const {
type = 'page',
touch = true,
scroll = true,
resetOnTouchEnds = false,
initialValue = { x: 0, y: 0 },
window = defaultWindow,
target = window,
eventFilter,
} = options;
let prevMouseEvent: MouseEvent | null = null;
let prevScrollX = 0;
let prevScrollY = 0;
const x = shallowRef(initialValue.x);
const y = shallowRef(initialValue.y);
const sourceType = shallowRef<UseMouseSourceType>(null);
const isExtractorFn = isFunction(type);
const extractor: UseMouseEventExtractor = isExtractorFn ? type : builtinExtractors[type];
const mouseHandler = (event: MouseEvent) => {
const result = extractor(event);
prevMouseEvent = event;
if (result) {
[x.value, y.value] = result;
sourceType.value = 'mouse';
}
if (window) {
prevScrollX = window.scrollX;
prevScrollY = window.scrollY;
}
};
const touchHandler = (event: TouchEvent) => {
if (event.touches.length) {
const result = extractor(event.touches[0]!);
if (result) {
[x.value, y.value] = result;
sourceType.value = 'touch';
}
}
};
// Keep page coordinates correct when scrolling without moving the pointer.
const scrollHandler = () => {
if (!prevMouseEvent || !window)
return;
const result = extractor(prevMouseEvent);
if (result) {
x.value = result[0] + window.scrollX - prevScrollX;
y.value = result[1] + window.scrollY - prevScrollY;
}
};
const reset = () => {
x.value = initialValue.x;
y.value = initialValue.y;
};
const filter = eventFilter ?? bypassFilter;
const mouseHandlerWrapper = createFilterWrapper(filter, mouseHandler);
const touchHandlerWrapper = createFilterWrapper(filter, touchHandler);
const scrollHandlerWrapper = createFilterWrapper(filter, scrollHandler);
const trackTouch = touch && !(isExtractorFn ? false : type === 'movement');
const trackScroll = scroll && !!window && (isExtractorFn ? true : type === 'page');
// A raw window/document/EventTarget is used directly (fast, non-reactive path
// in useEventListener). Refs/getters/element instances are resolved lazily via
// a getter so the listeners re-bind when the underlying element changes.
const listenTarget = isTarget(target)
? target
: (): EventTarget | null | undefined => unrefElement(target as MaybeComputedElementRef) as EventTarget | null | undefined;
if (target) {
const listenerOptions = { passive: true };
useEventListener(listenTarget, ['mousemove', 'dragover'], mouseHandlerWrapper as unknown as (e: Event) => void, listenerOptions);
if (trackTouch) {
useEventListener(listenTarget, ['touchstart', 'touchmove'], touchHandlerWrapper as unknown as (e: Event) => void, listenerOptions);
if (resetOnTouchEnds)
useEventListener(listenTarget, 'touchend', reset, listenerOptions);
}
if (trackScroll)
useEventListener(window, 'scroll', scrollHandlerWrapper as (e: Event) => void, listenerOptions);
}
return { x, y, sourceType };
}
/**
* `true` for an object that is itself an event target (window/document/element)
* and should be attached to directly, rather than unwrapped from a ref/getter.
*/
function isTarget(value: unknown): value is EventTarget {
return typeof value === 'object' && value !== null && 'addEventListener' in value;
}
@@ -1,252 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, shallowRef } from 'vue';
import { useMousePressed } from '.';
function dispatch(target: EventTarget, type: string): Event {
const event = new Event(type);
target.dispatchEvent(event);
return event;
}
describe(useMousePressed, () => {
it('starts not pressed by default', () => {
const scope = effectScope();
let res: ReturnType<typeof useMousePressed>;
scope.run(() => {
res = useMousePressed();
});
expect(res!.pressed.value).toBeFalsy();
expect(res!.sourceType.value).toBe(null);
scope.stop();
});
it('honors the initial value', () => {
const scope = effectScope();
let res: ReturnType<typeof useMousePressed>;
scope.run(() => {
res = useMousePressed({ initialValue: true });
});
expect(res!.pressed.value).toBeTruthy();
scope.stop();
});
it('sets pressed and mouse sourceType on mousedown, clears on mouseup', async () => {
const scope = effectScope();
let res: ReturnType<typeof useMousePressed>;
scope.run(() => {
res = useMousePressed();
});
dispatch(globalThis, 'mousedown');
await nextTick();
expect(res!.pressed.value).toBeTruthy();
expect(res!.sourceType.value).toBe('mouse');
dispatch(globalThis, 'mouseup');
await nextTick();
expect(res!.pressed.value).toBeFalsy();
expect(res!.sourceType.value).toBe(null);
scope.stop();
});
it('clears pressed on mouseleave', async () => {
const scope = effectScope();
let res: ReturnType<typeof useMousePressed>;
scope.run(() => {
res = useMousePressed();
});
dispatch(globalThis, 'mousedown');
await nextTick();
expect(res!.pressed.value).toBeTruthy();
dispatch(globalThis, 'mouseleave');
await nextTick();
expect(res!.pressed.value).toBeFalsy();
scope.stop();
});
it('tracks touch presses with touch sourceType', async () => {
const scope = effectScope();
let res: ReturnType<typeof useMousePressed>;
scope.run(() => {
res = useMousePressed();
});
dispatch(globalThis, 'touchstart');
await nextTick();
expect(res!.pressed.value).toBeTruthy();
expect(res!.sourceType.value).toBe('touch');
dispatch(globalThis, 'touchend');
await nextTick();
expect(res!.pressed.value).toBeFalsy();
expect(res!.sourceType.value).toBe(null);
scope.stop();
});
it('clears pressed on touchcancel', async () => {
const scope = effectScope();
let res: ReturnType<typeof useMousePressed>;
scope.run(() => {
res = useMousePressed();
});
dispatch(globalThis, 'touchstart');
await nextTick();
expect(res!.pressed.value).toBeTruthy();
dispatch(globalThis, 'touchcancel');
await nextTick();
expect(res!.pressed.value).toBeFalsy();
scope.stop();
});
it('does not register touch listeners when touch is disabled', async () => {
const addSpy = vi.spyOn(globalThis, 'addEventListener');
const scope = effectScope();
let res: ReturnType<typeof useMousePressed>;
scope.run(() => {
res = useMousePressed({ touch: false });
});
await nextTick();
const touchCalls = addSpy.mock.calls.filter(([name]) => String(name).startsWith('touch'));
expect(touchCalls).toHaveLength(0);
dispatch(globalThis, 'touchstart');
await nextTick();
expect(res!.pressed.value).toBeFalsy();
addSpy.mockRestore();
scope.stop();
});
it('tracks drag presses when drag is enabled', async () => {
const scope = effectScope();
let res: ReturnType<typeof useMousePressed>;
scope.run(() => {
res = useMousePressed();
});
dispatch(globalThis, 'dragstart');
await nextTick();
expect(res!.pressed.value).toBeTruthy();
expect(res!.sourceType.value).toBe('mouse');
dispatch(globalThis, 'dragend');
await nextTick();
expect(res!.pressed.value).toBeFalsy();
scope.stop();
});
it('clears pressed on drop', async () => {
const scope = effectScope();
let res: ReturnType<typeof useMousePressed>;
scope.run(() => {
res = useMousePressed();
});
dispatch(globalThis, 'dragstart');
await nextTick();
expect(res!.pressed.value).toBeTruthy();
dispatch(globalThis, 'drop');
await nextTick();
expect(res!.pressed.value).toBeFalsy();
scope.stop();
});
it('does not register drag listeners when drag is disabled', async () => {
const addSpy = vi.spyOn(globalThis, 'addEventListener');
const scope = effectScope();
scope.run(() => {
useMousePressed({ drag: false });
});
await nextTick();
const dragCalls = addSpy.mock.calls.filter(([name]) => String(name).startsWith('drag') || name === 'drop');
expect(dragCalls).toHaveLength(0);
addSpy.mockRestore();
scope.stop();
});
it('invokes onPressed and onReleased callbacks', async () => {
const onPressed = vi.fn();
const onReleased = vi.fn();
const scope = effectScope();
scope.run(() => {
useMousePressed({ onPressed, onReleased });
});
const down = dispatch(globalThis, 'mousedown');
await nextTick();
expect(onPressed).toHaveBeenCalledTimes(1);
expect(onPressed).toHaveBeenCalledWith(down);
const up = dispatch(globalThis, 'mouseup');
await nextTick();
expect(onReleased).toHaveBeenCalledTimes(1);
expect(onReleased).toHaveBeenCalledWith(up);
scope.stop();
});
it('attaches passive listeners and respects the capture option', async () => {
const addSpy = vi.spyOn(globalThis, 'addEventListener');
const scope = effectScope();
scope.run(() => {
useMousePressed({ capture: true });
});
await nextTick();
const downCall = addSpy.mock.calls.find(([name]) => name === 'mousedown');
expect(downCall).toBeDefined();
const opts = downCall![2] as AddEventListenerOptions;
expect(opts.passive).toBeTruthy();
expect(opts.capture).toBeTruthy();
addSpy.mockRestore();
scope.stop();
});
it('listens for press on a custom element target', async () => {
const el = document.createElement('div');
const elRef = shallowRef(el);
const addSpy = vi.spyOn(el, 'addEventListener');
const scope = effectScope();
let res: ReturnType<typeof useMousePressed>;
scope.run(() => {
res = useMousePressed({ target: elRef });
});
await nextTick();
expect(addSpy.mock.calls.some(([name]) => name === 'mousedown')).toBeTruthy();
dispatch(el, 'mousedown');
await nextTick();
expect(res!.pressed.value).toBeTruthy();
expect(res!.sourceType.value).toBe('mouse');
// release still listens on window
dispatch(globalThis, 'mouseup');
await nextTick();
expect(res!.pressed.value).toBeFalsy();
addSpy.mockRestore();
scope.stop();
});
it('does nothing when window is unavailable (SSR)', () => {
const scope = effectScope();
let res: ReturnType<typeof useMousePressed>;
scope.run(() => {
res = useMousePressed({ window: undefined, initialValue: true });
});
// returns refs without attaching listeners
expect(res!.pressed.value).toBeTruthy();
expect(res!.sourceType.value).toBe(null);
scope.stop();
});
});
@@ -1,130 +0,0 @@
import { computed, shallowRef } from 'vue';
import type { ComputedRef, ShallowRef } from 'vue';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { useEventListener } from '@/composables/browser/useEventListener';
import { unrefElement } from '@/composables/component/unrefElement';
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
import type { UseMouseSourceType } from '@/composables/browser/useMouse';
export type UseMousePressedEvent = MouseEvent | TouchEvent | DragEvent;
export interface UseMousePressedOptions extends ConfigurableWindow {
/**
* Listen to `touchstart`, `touchend`, and `touchcancel` events
*
* @default true
*/
touch?: boolean;
/**
* Listen to `dragstart`, `drop`, and `dragend` events
*
* @default true
*/
drag?: boolean;
/**
* Add event listeners with the `capture` option set to `true`
* (see [MDN](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#capture))
*
* @default false
*/
capture?: boolean;
/**
* Initial pressed state
*
* @default false
*/
initialValue?: boolean;
/**
* Element target to capture the press on. Accepts an element ref, getter,
* or component instance. Defaults to `window` when omitted.
*/
target?: MaybeComputedElementRef;
/**
* Callback invoked when a press starts
*/
onPressed?: (event: UseMousePressedEvent) => void;
/**
* Callback invoked when a press is released
*/
onReleased?: (event: UseMousePressedEvent) => void;
}
export interface UseMousePressedReturn {
pressed: ShallowRef<boolean>;
sourceType: ShallowRef<UseMouseSourceType>;
}
/**
* @name useMousePressed
* @category Browser
* @description Reactive mouse/touch/drag pressed state on a target, with the
* input source type and optional press/release callbacks.
*
* @param {UseMousePressedOptions} [options={}] Options
* @returns {UseMousePressedReturn} Reactive `pressed` and `sourceType`
*
* @example
* const { pressed, sourceType } = useMousePressed();
*
* @example
* // Track presses only on a specific element, ignore touch
* const { pressed } = useMousePressed({ target: el, touch: false });
*
* @since 0.0.15
*/
export function useMousePressed(options: UseMousePressedOptions = {}): UseMousePressedReturn {
const {
touch = true,
drag = true,
capture = false,
initialValue = false,
window = defaultWindow,
} = options;
const pressed = shallowRef(initialValue);
const sourceType = shallowRef<UseMouseSourceType>(null);
if (!window)
return { pressed, sourceType };
const onPressed = (srcType: UseMouseSourceType) => (event: UseMousePressedEvent): void => {
pressed.value = true;
sourceType.value = srcType;
options.onPressed?.(event);
};
const onReleased = (event: UseMousePressedEvent): void => {
pressed.value = false;
sourceType.value = null;
options.onReleased?.(event);
};
const target: ComputedRef<EventTarget> = computed(() => unrefElement(options.target) ?? window);
const listenerOptions = { passive: true, capture };
useEventListener(target, 'mousedown', onPressed('mouse') as (e: Event) => void, listenerOptions);
useEventListener(window, 'mouseleave', onReleased as (e: Event) => void, listenerOptions);
useEventListener(window, 'mouseup', onReleased as (e: Event) => void, listenerOptions);
if (drag) {
useEventListener(target, 'dragstart', onPressed('mouse') as (e: Event) => void, listenerOptions);
useEventListener(window, 'drop', onReleased as (e: Event) => void, listenerOptions);
useEventListener(window, 'dragend', onReleased as (e: Event) => void, listenerOptions);
}
if (touch) {
useEventListener(target, 'touchstart', onPressed('touch') as (e: Event) => void, listenerOptions);
useEventListener(window, 'touchend', onReleased as (e: Event) => void, listenerOptions);
useEventListener(window, 'touchcancel', onReleased as (e: Event) => void, listenerOptions);
}
return { pressed, sourceType };
}
@@ -1,193 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, ref } from 'vue';
import { useMutationObserver } from '.';
let instances: Array<{ cb: MutationCallback; observe: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn>; takeRecords: ReturnType<typeof vi.fn> }> = [];
class StubMutationObserver {
observe = vi.fn();
disconnect = vi.fn();
takeRecords = vi.fn(() => []);
cb: MutationCallback;
constructor(cb: MutationCallback) {
this.cb = cb;
instances.push(this);
}
}
describe(useMutationObserver, () => {
beforeEach(() => {
instances = [];
vi.stubGlobal('MutationObserver', StubMutationObserver);
});
afterEach(() => vi.unstubAllGlobals());
it('observes the target with the given options', () => {
const el = document.createElement('div');
const scope = effectScope();
scope.run(() => useMutationObserver(ref(el), vi.fn(), { attributes: true }));
expect(instances).toHaveLength(1);
expect(instances[0]!.observe).toHaveBeenCalledWith(el, { attributes: true });
scope.stop();
});
it('does not leak immediate/window into observer options', () => {
const el = document.createElement('div');
const scope = effectScope();
scope.run(() => useMutationObserver(ref(el), vi.fn(), { childList: true, immediate: true }));
expect(instances[0]!.observe).toHaveBeenCalledWith(el, { childList: true });
scope.stop();
});
it('disconnects on stop', () => {
const el = document.createElement('div');
const scope = effectScope();
let stop: () => void;
scope.run(() => {
stop = useMutationObserver(ref(el), vi.fn()).stop;
});
stop!();
expect(instances[0]!.disconnect).toHaveBeenCalled();
scope.stop();
});
it('forwards records to the callback', () => {
const el = document.createElement('div');
const callback = vi.fn();
const scope = effectScope();
scope.run(() => useMutationObserver(ref(el), callback));
const records = [{ type: 'attributes' } as MutationRecord];
instances[0]!.cb(records, instances[0] as unknown as MutationObserver);
expect(callback).toHaveBeenCalledWith(records, expect.anything());
scope.stop();
});
it('observes an array of targets with a single observer', () => {
const a = document.createElement('div');
const b = document.createElement('span');
const scope = effectScope();
scope.run(() => useMutationObserver([ref(a), b], vi.fn(), { childList: true }));
expect(instances).toHaveLength(1);
expect(instances[0]!.observe).toHaveBeenCalledTimes(2);
expect(instances[0]!.observe).toHaveBeenCalledWith(a, { childList: true });
expect(instances[0]!.observe).toHaveBeenCalledWith(b, { childList: true });
scope.stop();
});
it('accepts a getter returning an array of targets', () => {
const a = document.createElement('div');
const b = document.createElement('span');
const scope = effectScope();
scope.run(() => useMutationObserver(() => [a, b], vi.fn(), { childList: true }));
expect(instances).toHaveLength(1);
expect(instances[0]!.observe).toHaveBeenCalledTimes(2);
scope.stop();
});
it('deduplicates repeated targets', () => {
const a = document.createElement('div');
const scope = effectScope();
scope.run(() => useMutationObserver([a, a, ref(a)], vi.fn(), { childList: true }));
expect(instances[0]!.observe).toHaveBeenCalledTimes(1);
scope.stop();
});
it('skips nullish targets', () => {
const scope = effectScope();
scope.run(() => useMutationObserver([ref(null), ref(undefined)], vi.fn(), { childList: true }));
expect(instances).toHaveLength(0);
scope.stop();
});
it('re-observes when a reactive target changes', async () => {
const el = document.createElement('div');
const target = ref<HTMLElement | null>(null);
const scope = effectScope();
scope.run(() => useMutationObserver(target, vi.fn(), { childList: true }));
await nextTick();
expect(instances).toHaveLength(0);
target.value = el;
await nextTick();
expect(instances).toHaveLength(1);
expect(instances[0]!.observe).toHaveBeenCalledWith(el, { childList: true });
scope.stop();
});
it('does not observe when immediate is false, then resumes', async () => {
const el = document.createElement('div');
const scope = effectScope();
let api: ReturnType<typeof useMutationObserver>;
scope.run(() => {
api = useMutationObserver(ref(el), vi.fn(), { attributes: true, immediate: false });
});
await nextTick();
expect(instances).toHaveLength(0);
expect(api!.isActive.value).toBeFalsy();
api!.resume();
await nextTick();
expect(instances).toHaveLength(1);
expect(api!.isActive.value).toBeTruthy();
scope.stop();
});
it('pause disconnects and flips isActive, resume re-observes', async () => {
const el = document.createElement('div');
const scope = effectScope();
let api: ReturnType<typeof useMutationObserver>;
scope.run(() => {
api = useMutationObserver(ref(el), vi.fn(), { attributes: true });
});
expect(instances).toHaveLength(1);
api!.pause();
expect(instances[0]!.disconnect).toHaveBeenCalled();
expect(api!.isActive.value).toBeFalsy();
api!.resume();
await nextTick();
expect(instances).toHaveLength(2);
scope.stop();
});
it('takeRecords proxies to the active observer and returns undefined when inactive', () => {
const el = document.createElement('div');
const scope = effectScope();
let api: ReturnType<typeof useMutationObserver>;
scope.run(() => {
api = useMutationObserver(ref(el), vi.fn());
});
expect(api!.takeRecords()).toEqual([]);
expect(instances[0]!.takeRecords).toHaveBeenCalled();
api!.stop();
expect(api!.takeRecords()).toBeUndefined();
scope.stop();
});
it('reports isSupported false when MutationObserver is missing', () => {
const scope = effectScope();
let api: ReturnType<typeof useMutationObserver>;
const el = document.createElement('div');
scope.run(() => {
api = useMutationObserver(ref(el), vi.fn(), { window: { foo: 1 } as unknown as Window & typeof globalThis });
});
expect(api!.isSupported.value).toBeFalsy();
expect(instances).toHaveLength(0);
scope.stop();
});
});
@@ -1,140 +0,0 @@
import { computed, readonly, ref, toValue, watch } from 'vue';
import type { MaybeRefOrGetter, Ref } from 'vue';
import { toArray } from '@robonen/stdlib';
import type { ConfigurableWindow } from '@/types';
import { defaultWindow } from '@/types';
import type { MaybeComputedElementRef, MaybeElement } from '@/composables/component/unrefElement';
import { unrefElement } from '@/composables/component/unrefElement';
import { useSupported } from '@/composables/browser/useSupported';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
export interface UseMutationObserverOptions extends MutationObserverInit, ConfigurableWindow {
/**
* Start observing immediately once a target is available
*
* @default true
*/
immediate?: boolean;
}
export interface UseMutationObserverReturn {
isSupported: Readonly<Ref<boolean>>;
/**
* Whether the observer is currently active (not paused or stopped)
*/
isActive: Readonly<Ref<boolean>>;
/**
* Temporarily disconnect the observer without tearing down the watcher.
* Re-observe with `resume`.
*/
pause: () => void;
/**
* Re-attach the observer to the current target(s) after a `pause`.
*/
resume: () => void;
/**
* Permanently stop observing and dispose the watcher.
*/
stop: () => void;
/**
* Synchronously take and clear the observer's record queue
*/
takeRecords: () => MutationRecord[] | undefined;
}
/**
* @name useMutationObserver
* @category Browser
* @description Watch for changes to the DOM tree via `MutationObserver`.
* Accepts a single target, an array of targets, or a getter returning either.
*
* @param {MaybeComputedElementRef | MaybeComputedElementRef[] | MaybeRefOrGetter<MaybeElement[]>} target Element(s) to observe
* @param {MutationCallback} callback Invoked with the mutation records
* @param {UseMutationObserverOptions} [options={}] Observer options (childList, attributes, …)
* @returns {UseMutationObserverReturn} `isSupported`, `isActive`, `pause`, `resume`, `stop`, and `takeRecords`
*
* @example
* useMutationObserver(el, (records) => {
* console.log(records);
* }, { attributes: true });
*
* @example
* const { pause, resume } = useMutationObserver([elA, elB], onMutate, { childList: true });
*
* @since 0.0.15
*/
export function useMutationObserver(
target: MaybeComputedElementRef | MaybeComputedElementRef[] | MaybeRefOrGetter<MaybeElement[]>,
callback: MutationCallback,
options: UseMutationObserverOptions = {},
): UseMutationObserverReturn {
const { window = defaultWindow, immediate = true, ...observerOptions } = options;
const isSupported = useSupported(() => window && 'MutationObserver' in window);
let observer: MutationObserver | undefined;
const isActive = ref(immediate);
const targets = computed(() => {
const value = toArray(toValue(target));
const set = new Set<Element>();
for (const item of value) {
const el = unrefElement(item as MaybeComputedElementRef);
if (el)
set.add(el);
}
return set;
});
const cleanup = () => {
if (observer) {
observer.disconnect();
observer = undefined;
}
};
const takeRecords = () => observer?.takeRecords();
const stopWatch = watch(
() => [targets.value, isActive.value] as const,
([els, active]) => {
cleanup();
if (!active || !isSupported.value || !window || !els.size)
return;
observer = new MutationObserver(callback);
for (const el of els)
observer.observe(el, observerOptions);
},
{ immediate: true, flush: 'post' },
);
const resume = () => {
isActive.value = true;
};
const pause = () => {
cleanup();
isActive.value = false;
};
const stop = () => {
cleanup();
stopWatch();
};
tryOnScopeDispose(stop);
return {
isSupported,
isActive: readonly(isActive),
pause,
resume,
stop,
takeRecords,
};
}
@@ -1,164 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick } from 'vue';
import { useNetwork } from '.';
afterEach(() => vi.unstubAllGlobals());
describe(useNetwork, () => {
it('exposes the full reactive network state shape', () => {
const scope = effectScope();
let state: ReturnType<typeof useNetwork>;
scope.run(() => {
state = useNetwork();
});
expect(state!.isOnline.value).toBe(navigator.onLine);
expect(state!.type.value).toBe('unknown');
// while online on mount, offlineAt stays undefined and onlineAt is stamped
expect(state!.offlineAt.value).toBeUndefined();
expect(typeof state!.onlineAt.value).toBe('number');
expect(state!.downlink.value).toBeUndefined();
expect(state!.downlinkMax.value).toBeUndefined();
expect(state!.rtt.value).toBeUndefined();
expect(state!.effectiveType.value).toBeUndefined();
expect(state!.saveData.value).toBeUndefined();
scope.stop();
});
it('reflects the initial navigator.onLine from a supplied window', () => {
const fakeWindow = {
navigator: { onLine: false },
addEventListener: () => {},
removeEventListener: () => {},
} as unknown as Window;
const scope = effectScope();
let state: ReturnType<typeof useNetwork>;
scope.run(() => {
state = useNetwork({ window: fakeWindow });
});
expect(state!.isOnline.value).toBeFalsy();
scope.stop();
});
it('records offlineAt and onlineAt timestamps on transitions', async () => {
const scope = effectScope();
let state: ReturnType<typeof useNetwork>;
scope.run(() => {
state = useNetwork();
});
expect(state!.offlineAt.value).toBeUndefined();
globalThis.dispatchEvent(new Event('offline'));
await nextTick();
expect(state!.isOnline.value).toBeFalsy();
expect(typeof state!.offlineAt.value).toBe('number');
globalThis.dispatchEvent(new Event('online'));
await nextTick();
expect(state!.isOnline.value).toBeTruthy();
expect(typeof state!.onlineAt.value).toBe('number');
scope.stop();
});
it('stays inert and reports online when window is undefined (SSR)', () => {
const scope = effectScope();
let state: ReturnType<typeof useNetwork>;
scope.run(() => {
state = useNetwork({ window: undefined });
});
expect(state!.isOnline.value).toBeTruthy();
expect(state!.isSupported.value).toBeFalsy();
expect(state!.type.value).toBe('unknown');
expect(state!.downlink.value).toBeUndefined();
scope.stop();
});
it('isSupported is false when Network Information API is unavailable', () => {
const fakeWindow = {
navigator: { onLine: true },
addEventListener: () => {},
removeEventListener: () => {},
} as unknown as Window;
const scope = effectScope();
let state: ReturnType<typeof useNetwork>;
scope.run(() => {
state = useNetwork({ window: fakeWindow });
});
expect(state!.isSupported.value).toBeFalsy();
scope.stop();
});
it('reads connection info and listens for change events when supported', () => {
const listeners: Record<string, Array<(e: Event) => void>> = {};
const connection = {
downlink: 10,
downlinkMax: 100,
effectiveType: '4g',
rtt: 50,
saveData: true,
type: 'wifi',
addEventListener: (event: string, cb: (e: Event) => void) => {
(listeners[event] ??= []).push(cb);
},
removeEventListener: () => {},
};
const fakeWindow = {
navigator: { onLine: true, connection },
addEventListener: () => {},
removeEventListener: () => {},
} as unknown as Window;
const scope = effectScope();
let state: ReturnType<typeof useNetwork>;
scope.run(() => {
state = useNetwork({ window: fakeWindow });
});
expect(state!.isSupported.value).toBeTruthy();
expect(state!.downlink.value).toBe(10);
expect(state!.downlinkMax.value).toBe(100);
expect(state!.effectiveType.value).toBe('4g');
expect(state!.rtt.value).toBe(50);
expect(state!.saveData.value).toBeTruthy();
expect(state!.type.value).toBe('wifi');
// a registered change listener should re-read connection state
connection.downlink = 5;
connection.effectiveType = '3g';
listeners.change?.forEach(cb => cb(new Event('change')));
expect(state!.downlink.value).toBe(5);
expect(state!.effectiveType.value).toBe('3g');
scope.stop();
});
it('falls back to "unknown" type when connection.type is missing', () => {
const connection = {
downlink: 8,
effectiveType: '4g',
addEventListener: () => {},
removeEventListener: () => {},
};
const fakeWindow = {
navigator: { onLine: true, connection },
addEventListener: () => {},
removeEventListener: () => {},
} as unknown as Window;
const scope = effectScope();
let state: ReturnType<typeof useNetwork>;
scope.run(() => {
state = useNetwork({ window: fakeWindow });
});
expect(state!.isSupported.value).toBeTruthy();
expect(state!.type.value).toBe('unknown');
expect(state!.downlink.value).toBe(8);
scope.stop();
});
});
@@ -1,157 +0,0 @@
import { shallowReadonly, shallowRef } from 'vue';
import type { ShallowRef } from 'vue';
import { timestamp } from '@robonen/stdlib';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { useEventListener } from '@/composables/browser/useEventListener';
import { useSupported } from '@/composables/browser/useSupported';
export type NetworkType
= | 'bluetooth'
| 'cellular'
| 'ethernet'
| 'none'
| 'wifi'
| 'wimax'
| 'other'
| 'unknown';
export type NetworkEffectiveType = 'slow-2g' | '2g' | '3g' | '4g' | undefined;
export interface UseNetworkOptions extends ConfigurableWindow {}
export interface UseNetworkReturn {
/**
* Whether the Network Information API (`navigator.connection`) is supported.
*/
isSupported: Readonly<ShallowRef<boolean>>;
/**
* Whether the browser is currently online (`navigator.onLine`).
*/
isOnline: Readonly<ShallowRef<boolean>>;
/**
* The timestamp of the last time the browser went offline, in ms.
*/
offlineAt: Readonly<ShallowRef<number | undefined>>;
/**
* The timestamp of the last time the browser came back online, in ms.
*/
onlineAt: Readonly<ShallowRef<number | undefined>>;
/**
* The estimated effective bandwidth in megabits per second.
*/
downlink: Readonly<ShallowRef<number | undefined>>;
/**
* The maximum downlink speed of the underlying connection technology, in Mbps.
*/
downlinkMax: Readonly<ShallowRef<number | undefined>>;
/**
* The effective type of the connection (`slow-2g`, `2g`, `3g`, or `4g`).
*/
effectiveType: Readonly<ShallowRef<NetworkEffectiveType>>;
/**
* The estimated effective round-trip time of the current connection, in ms.
*/
rtt: Readonly<ShallowRef<number | undefined>>;
/**
* Whether the user has requested a reduced data usage mode.
*/
saveData: Readonly<ShallowRef<boolean | undefined>>;
/**
* The type of connection a device is using to communicate with the network.
*/
type: Readonly<ShallowRef<NetworkType>>;
}
interface NetworkInformation extends EventTarget {
readonly downlink?: number;
readonly downlinkMax?: number;
readonly effectiveType?: NetworkEffectiveType;
readonly rtt?: number;
readonly saveData?: boolean;
readonly type?: NetworkType;
}
/**
* @name useNetwork
* @category Browser
* @description Reactive Network Information API state plus online/offline status.
*
* @param {UseNetworkOptions} [options={}] Options
* @returns {UseNetworkReturn} Reactive online status, transition timestamps, and connection info
*
* @example
* const { isOnline, offlineAt, downlink, effectiveType, saveData, type } = useNetwork();
*
* @since 0.0.15
*/
export function useNetwork(options: UseNetworkOptions = {}): UseNetworkReturn {
const { window = defaultWindow } = options;
const navigator = window?.navigator;
const isSupported = useSupported(() => !!navigator && 'connection' in navigator);
const isOnline = shallowRef(navigator?.onLine ?? true);
const saveData = shallowRef<boolean | undefined>(undefined);
const offlineAt = shallowRef<number | undefined>(undefined);
const onlineAt = shallowRef<number | undefined>(undefined);
const downlink = shallowRef<number | undefined>(undefined);
const downlinkMax = shallowRef<number | undefined>(undefined);
const rtt = shallowRef<number | undefined>(undefined);
const effectiveType = shallowRef<NetworkEffectiveType>(undefined);
const type = shallowRef<NetworkType>('unknown');
const connection = navigator && 'connection' in navigator
? (navigator as Navigator & { connection?: NetworkInformation }).connection
: undefined;
function updateNetworkInformation(): void {
if (!navigator)
return;
isOnline.value = navigator.onLine;
offlineAt.value = isOnline.value ? offlineAt.value : timestamp();
onlineAt.value = isOnline.value ? timestamp() : onlineAt.value;
if (connection) {
downlink.value = connection.downlink;
downlinkMax.value = connection.downlinkMax;
effectiveType.value = connection.effectiveType;
rtt.value = connection.rtt;
saveData.value = connection.saveData;
type.value = connection.type ?? 'unknown';
}
}
const listenerOptions = { passive: true } as const;
if (window) {
useEventListener(window, 'offline', () => {
isOnline.value = false;
offlineAt.value = timestamp();
}, listenerOptions);
useEventListener(window, 'online', () => {
isOnline.value = true;
onlineAt.value = timestamp();
}, listenerOptions);
}
if (connection)
useEventListener(connection, 'change', updateNetworkInformation, listenerOptions);
updateNetworkInformation();
return {
isSupported,
isOnline: shallowReadonly(isOnline),
saveData: shallowReadonly(saveData),
offlineAt: shallowReadonly(offlineAt),
onlineAt: shallowReadonly(onlineAt),
downlink: shallowReadonly(downlink),
downlinkMax: shallowReadonly(downlinkMax),
effectiveType: shallowReadonly(effectiveType),
rtt: shallowReadonly(rtt),
type: shallowReadonly(type),
};
}
@@ -1,44 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick } from 'vue';
import { useOnline } from '.';
afterEach(() => vi.unstubAllGlobals());
describe(useOnline, () => {
it('reflects the initial navigator.onLine', () => {
const scope = effectScope();
let online: ReturnType<typeof useOnline>;
scope.run(() => {
online = useOnline();
});
expect(online!.value).toBe(navigator.onLine);
scope.stop();
});
it('updates on offline/online events', async () => {
const scope = effectScope();
let online: ReturnType<typeof useOnline>;
scope.run(() => {
online = useOnline();
});
globalThis.dispatchEvent(new Event('offline'));
await nextTick();
expect(online!.value).toBeFalsy();
globalThis.dispatchEvent(new Event('online'));
await nextTick();
expect(online!.value).toBeTruthy();
scope.stop();
});
it('defaults to true when there is no window (SSR)', () => {
const scope = effectScope();
let online: ReturnType<typeof useOnline>;
scope.run(() => {
online = useOnline({ window: undefined as any });
});
expect(online!.value).toBeTruthy();
scope.stop();
});
});
@@ -1,38 +0,0 @@
import { shallowReadonly, shallowRef } from 'vue';
import type { ShallowRef } from 'vue';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { useEventListener } from '@/composables/browser/useEventListener';
export interface UseOnlineOptions extends ConfigurableWindow {}
/**
* @name useOnline
* @category Browser
* @description Reactive online/offline status based on `navigator.onLine`.
* For connection details (effectiveType, downlink, saveData, transition
* timestamps, ...) use {@link useNetwork} instead.
*
* @param {UseOnlineOptions} [options={}] Options
* @returns {Readonly<ShallowRef<boolean>>} Whether the browser is online
*
* @example
* const online = useOnline();
*
* @since 0.0.15
*/
export function useOnline(options: UseOnlineOptions = {}): Readonly<ShallowRef<boolean>> {
const { window = defaultWindow } = options;
const isOnline = shallowRef(window?.navigator?.onLine ?? true);
useEventListener(window, 'online', () => {
isOnline.value = true;
}, { passive: true });
useEventListener(window, 'offline', () => {
isOnline.value = false;
}, { passive: true });
return shallowReadonly(isOnline);
}
@@ -1,95 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { effectScope, isReadonly, nextTick } from 'vue';
import { usePageLeave } from '.';
function withScope<T>(fn: () => T): { value: T; stop: () => void } {
const scope = effectScope();
let value!: T;
scope.run(() => {
value = fn();
});
return { value, stop: () => scope.stop() };
}
describe(usePageLeave, () => {
it('is false initially', () => {
const { value: isLeft, stop } = withScope(() => usePageLeave());
expect(isLeft.value).toBeFalsy();
stop();
});
it('returns a writable shallow ref', () => {
const { value: isLeft, stop } = withScope(() => usePageLeave());
expect(isReadonly(isLeft)).toBeFalsy();
stop();
});
it('becomes true when the pointer leaves the document', async () => {
const { value: isLeft, stop } = withScope(() => usePageLeave());
document.documentElement.dispatchEvent(new MouseEvent('mouseleave'));
await nextTick();
expect(isLeft.value).toBeTruthy();
document.documentElement.dispatchEvent(new MouseEvent('mouseenter'));
await nextTick();
expect(isLeft.value).toBeFalsy();
stop();
});
it('uses mouseout relatedTarget to detect leaving', async () => {
const { value: isLeft, stop } = withScope(() => usePageLeave());
// No relatedTarget => pointer left the page.
globalThis.dispatchEvent(new MouseEvent('mouseout'));
await nextTick();
expect(isLeft.value).toBeTruthy();
// relatedTarget present => pointer moved to another element, still on page.
globalThis.dispatchEvent(new MouseEvent('mouseout', { relatedTarget: document.body }));
await nextTick();
expect(isLeft.value).toBeFalsy();
stop();
});
it('invokes onChange with the new value and event only on change', async () => {
const onChange = vi.fn();
const { stop } = withScope(() => usePageLeave({ onChange }));
document.documentElement.dispatchEvent(new MouseEvent('mouseleave'));
await nextTick();
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenLastCalledWith(true, expect.any(MouseEvent));
// Repeated leave should not fire again (no state change).
document.documentElement.dispatchEvent(new MouseEvent('mouseleave'));
await nextTick();
expect(onChange).toHaveBeenCalledTimes(1);
document.documentElement.dispatchEvent(new MouseEvent('mouseenter'));
await nextTick();
expect(onChange).toHaveBeenCalledTimes(2);
expect(onChange).toHaveBeenLastCalledWith(false, expect.any(MouseEvent));
stop();
});
it('does not throw and stays false when window is undefined (SSR)', () => {
const { value: isLeft, stop } = withScope(() =>
usePageLeave({ window: undefined }),
);
expect(isLeft.value).toBeFalsy();
stop();
});
it('binds listeners to a custom window', async () => {
const onChange = vi.fn();
const { stop } = withScope(() => usePageLeave({ window: globalThis as unknown as Window, onChange }));
document.documentElement.dispatchEvent(new MouseEvent('mouseleave'));
await nextTick();
expect(onChange).toHaveBeenCalledWith(true, expect.any(MouseEvent));
document.documentElement.dispatchEvent(new MouseEvent('mouseenter'));
await nextTick();
stop();
});
});
@@ -1,71 +0,0 @@
import { shallowRef } from 'vue';
import type { ShallowRef } from 'vue';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { useEventListener } from '@/composables/browser/useEventListener';
export interface UsePageLeaveOptions extends ConfigurableWindow {
/**
* Called whenever the leave state flips, receiving the new value and the
* originating mouse event.
*
* @default undefined
*/
onChange?: (isLeft: boolean, event: MouseEvent) => void;
}
export type UsePageLeaveReturn = ShallowRef<boolean>;
/**
* @name usePageLeave
* @category Browser
* @description Reactive flag indicating whether the mouse has left the page.
*
* @param {UsePageLeaveOptions} [options={}] Options (custom `window`, `onChange` callback)
* @returns {UsePageLeaveReturn} Whether the pointer has left the page
*
* @example
* const hasLeft = usePageLeave();
*
* @example
* usePageLeave({
* onChange: (isLeft) => {
* if (isLeft) showExitIntentModal();
* },
* });
*
* @since 0.0.15
*/
export function usePageLeave(options: UsePageLeaveOptions = {}): UsePageLeaveReturn {
const { window = defaultWindow, onChange } = options;
const isLeft = shallowRef(false);
const update = (left: boolean, event: MouseEvent) => {
if (left === isLeft.value)
return;
isLeft.value = left;
onChange?.(left, event);
};
if (window) {
const documentElement = window.document.documentElement;
const listenerOptions = { passive: true } as const;
useEventListener(window, 'mouseout', (event) => {
const from = event.relatedTarget || (event as MouseEvent & { toElement?: EventTarget }).toElement;
update(!from, event);
}, listenerOptions);
useEventListener(documentElement, 'mouseleave', (event) => {
update(true, event);
}, listenerOptions);
useEventListener(documentElement, 'mouseenter', (event) => {
update(false, event);
}, listenerOptions);
}
return isLeft;
}
@@ -3,7 +3,7 @@ import type { Ref, ShallowRef } from 'vue';
import { isString } from '@robonen/stdlib';
import { defaultNavigator } from '@/types';
import type { ConfigurableNavigator } from '@/types';
import { useSupported } from '@/composables/browser/useSupported';
import { useSupported } from '@/composables/utilities/useSupported';
import { useEventListener } from '@/composables/browser/useEventListener';
/**
@@ -1,218 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, shallowRef } from 'vue';
import { usePointer } from '.';
interface PointerProps {
x?: number;
y?: number;
pressure?: number;
pointerId?: number;
tiltX?: number;
tiltY?: number;
width?: number;
height?: number;
twist?: number;
pointerType?: string;
}
function dispatchPointer(target: EventTarget, type: string, props: PointerProps = {}) {
const event = new Event(type, { bubbles: true });
for (const [key, value] of Object.entries(props))
Object.defineProperty(event, key, { value, configurable: true });
target.dispatchEvent(event);
return event;
}
describe(usePointer, () => {
it('starts from the default state', () => {
const scope = effectScope();
let ptr: ReturnType<typeof usePointer>;
scope.run(() => {
ptr = usePointer();
});
expect(ptr!.x.value).toBe(0);
expect(ptr!.y.value).toBe(0);
expect(ptr!.pressure.value).toBe(0);
expect(ptr!.pointerId.value).toBe(0);
expect(ptr!.tiltX.value).toBe(0);
expect(ptr!.tiltY.value).toBe(0);
expect(ptr!.width.value).toBe(0);
expect(ptr!.height.value).toBe(0);
expect(ptr!.twist.value).toBe(0);
expect(ptr!.pointerType.value).toBe(null);
expect(ptr!.isInside.value).toBeFalsy();
scope.stop();
});
it('merges the initial value over defaults', () => {
const scope = effectScope();
let ptr: ReturnType<typeof usePointer>;
scope.run(() => {
ptr = usePointer({ initialValue: { x: 12, pressure: 0.5 } });
});
expect(ptr!.x.value).toBe(12);
expect(ptr!.pressure.value).toBe(0.5);
expect(ptr!.y.value).toBe(0);
scope.stop();
});
it('tracks pointermove and populates the full state', async () => {
const scope = effectScope();
let ptr: ReturnType<typeof usePointer>;
scope.run(() => {
ptr = usePointer();
});
dispatchPointer(globalThis, 'pointermove', {
x: 100,
y: 200,
pressure: 0.7,
pointerId: 3,
tiltX: 10,
tiltY: -5,
width: 4,
height: 6,
twist: 90,
pointerType: 'pen',
});
await nextTick();
expect(ptr!.x.value).toBe(100);
expect(ptr!.y.value).toBe(200);
expect(ptr!.pressure.value).toBe(0.7);
expect(ptr!.pointerId.value).toBe(3);
expect(ptr!.tiltX.value).toBe(10);
expect(ptr!.tiltY.value).toBe(-5);
expect(ptr!.width.value).toBe(4);
expect(ptr!.height.value).toBe(6);
expect(ptr!.twist.value).toBe(90);
expect(ptr!.pointerType.value).toBe('pen');
scope.stop();
});
it('sets isInside true on pointer activity and false on pointerleave', async () => {
const scope = effectScope();
let ptr: ReturnType<typeof usePointer>;
scope.run(() => {
ptr = usePointer();
});
expect(ptr!.isInside.value).toBeFalsy();
dispatchPointer(globalThis, 'pointerdown', { x: 1, y: 2, pointerType: 'mouse' });
await nextTick();
expect(ptr!.isInside.value).toBeTruthy();
dispatchPointer(globalThis, 'pointerleave');
await nextTick();
expect(ptr!.isInside.value).toBeFalsy();
scope.stop();
});
it('tracks pointerdown and pointerup', async () => {
const scope = effectScope();
let ptr: ReturnType<typeof usePointer>;
scope.run(() => {
ptr = usePointer();
});
dispatchPointer(globalThis, 'pointerdown', { x: 5, y: 5, pointerType: 'mouse' });
await nextTick();
expect(ptr!.x.value).toBe(5);
dispatchPointer(globalThis, 'pointerup', { x: 9, y: 9, pointerType: 'mouse' });
await nextTick();
expect(ptr!.x.value).toBe(9);
expect(ptr!.y.value).toBe(9);
scope.stop();
});
it('ignores events whose pointerType is not allowed', async () => {
const scope = effectScope();
let ptr: ReturnType<typeof usePointer>;
scope.run(() => {
ptr = usePointer({ pointerTypes: ['pen'], initialValue: { x: 1, y: 2 } });
});
dispatchPointer(globalThis, 'pointermove', { x: 50, y: 60, pointerType: 'mouse' });
await nextTick();
// mouse rejected: position unchanged, but isInside still flips true
expect(ptr!.x.value).toBe(1);
expect(ptr!.y.value).toBe(2);
expect(ptr!.isInside.value).toBeTruthy();
dispatchPointer(globalThis, 'pointermove', { x: 50, y: 60, pointerType: 'pen' });
await nextTick();
expect(ptr!.x.value).toBe(50);
expect(ptr!.y.value).toBe(60);
expect(ptr!.pointerType.value).toBe('pen');
scope.stop();
});
it('attaches passive listeners', () => {
const addSpy = vi.spyOn(globalThis, 'addEventListener');
const scope = effectScope();
scope.run(() => {
usePointer();
});
const moveCall = addSpy.mock.calls.find(([name]) => name === 'pointermove');
expect(moveCall).toBeDefined();
expect((moveCall![2] as AddEventListenerOptions).passive).toBeTruthy();
addSpy.mockRestore();
scope.stop();
});
it('listens on a custom element target', async () => {
const el = document.createElement('div');
const elRef = shallowRef(el);
const addSpy = vi.spyOn(el, 'addEventListener');
const scope = effectScope();
let ptr: ReturnType<typeof usePointer>;
scope.run(() => {
ptr = usePointer({ target: elRef });
});
await nextTick();
expect(addSpy.mock.calls.some(([name]) => name === 'pointermove')).toBeTruthy();
dispatchPointer(el, 'pointermove', { x: 7, y: 8, pointerType: 'mouse' });
await nextTick();
expect(ptr!.x.value).toBe(7);
expect(ptr!.y.value).toBe(8);
addSpy.mockRestore();
scope.stop();
});
it('exposes writable state refs', () => {
const scope = effectScope();
let ptr: ReturnType<typeof usePointer>;
scope.run(() => {
ptr = usePointer();
});
ptr!.x.value = 42;
expect(ptr!.x.value).toBe(42);
scope.stop();
});
it('does nothing when window is unavailable (SSR)', () => {
const scope = effectScope();
let ptr: ReturnType<typeof usePointer>;
scope.run(() => {
ptr = usePointer({ window: undefined, target: undefined });
});
expect(ptr!.x.value).toBe(0);
expect(ptr!.y.value).toBe(0);
expect(ptr!.pointerType.value).toBe(null);
expect(ptr!.isInside.value).toBeFalsy();
scope.stop();
});
});
@@ -1,162 +0,0 @@
import { computed, shallowRef } from 'vue';
import type { ShallowRef, WritableComputedRef } from 'vue';
import { pick } from '@robonen/stdlib';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { useEventListener } from '@/composables/browser/useEventListener';
import { unrefElement } from '@/composables/component/unrefElement';
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
export type UsePointerType = 'mouse' | 'touch' | 'pen';
export interface UsePointerState {
x: number;
y: number;
pressure: number;
pointerId: number;
tiltX: number;
tiltY: number;
width: number;
height: number;
twist: number;
pointerType: UsePointerType | null;
}
export interface UsePointerOptions extends ConfigurableWindow {
/**
* Pointer types that should be listened to.
*
* @default ['mouse', 'touch', 'pen']
*/
pointerTypes?: UsePointerType[];
/**
* Initial pointer state.
*/
initialValue?: Partial<UsePointerState>;
/**
* Target to attach the listeners to. Accepts a window, document, element ref,
* getter, or component instance.
*
* @default window
*/
target?: MaybeComputedElementRef | Window | Document;
}
export interface UsePointerReturn {
x: WritableComputedRef<number>;
y: WritableComputedRef<number>;
pressure: WritableComputedRef<number>;
pointerId: WritableComputedRef<number>;
tiltX: WritableComputedRef<number>;
tiltY: WritableComputedRef<number>;
width: WritableComputedRef<number>;
height: WritableComputedRef<number>;
twist: WritableComputedRef<number>;
pointerType: WritableComputedRef<UsePointerType | null>;
isInside: ShallowRef<boolean>;
}
const defaultState: UsePointerState = {
x: 0,
y: 0,
pointerId: 0,
pressure: 0,
tiltX: 0,
tiltY: 0,
width: 0,
height: 0,
twist: 0,
pointerType: null,
};
const keys = Object.keys(defaultState) as Array<keyof UsePointerState>;
/**
* @name usePointer
* @category Browser
* @description Reactive pointer state (position, pressure, tilt, size, and
* pointer type) sourced from pointer events on a target, plus whether the
* pointer is currently inside it.
*
* @param {UsePointerOptions} [options={}] Options
* @returns {UsePointerReturn} Reactive pointer state refs and `isInside`
*
* @example
* const { x, y, pressure, pointerType, isInside } = usePointer();
*
* @example
* // Track a specific element, pen only
* const { x, y, tiltX, tiltY } = usePointer({ target: el, pointerTypes: ['pen'] });
*
* @since 0.0.15
*/
export function usePointer(options: UsePointerOptions = {}): UsePointerReturn {
const {
pointerTypes,
initialValue = {},
window = defaultWindow,
target = window,
} = options;
const isInside = shallowRef(false);
const state = shallowRef<UsePointerState>({
...defaultState,
...initialValue,
});
const handler = (event: PointerEvent) => {
isInside.value = true;
if (pointerTypes && !pointerTypes.includes(event.pointerType as UsePointerType))
return;
state.value = pick(event, keys) as UsePointerState;
};
// A raw window/document/EventTarget is used directly (fast, non-reactive path
// in useEventListener). Refs/getters/element instances are resolved lazily via
// a getter so the listeners re-bind when the underlying element changes.
const listenTarget = isTarget(target)
? target
: (): EventTarget | null | undefined => unrefElement(target as MaybeComputedElementRef) as EventTarget | null | undefined;
if (target) {
const listenerOptions = { passive: true };
useEventListener(listenTarget, ['pointerdown', 'pointermove', 'pointerup'], handler as (e: Event) => void, listenerOptions);
useEventListener(listenTarget, 'pointerleave', () => (isInside.value = false), listenerOptions);
}
// Derive a writable ref per field that reads/writes through the single
// shallowRef holding the whole state, matching VueUse's `toRefs(shallowRef)`.
const toField = <K extends keyof UsePointerState>(key: K): WritableComputedRef<UsePointerState[K]> =>
computed({
get: () => state.value[key],
set: value => (state.value = { ...state.value, [key]: value }),
});
return {
x: toField('x'),
y: toField('y'),
pressure: toField('pressure'),
pointerId: toField('pointerId'),
tiltX: toField('tiltX'),
tiltY: toField('tiltY'),
width: toField('width'),
height: toField('height'),
twist: toField('twist'),
pointerType: toField('pointerType'),
isInside,
};
}
/**
* `true` for an object that is itself an event target (window/document/element)
* and should be attached to directly, rather than unwrapped from a ref/getter.
*/
function isTarget(value: unknown): value is EventTarget {
return typeof value === 'object' && value !== null && 'addEventListener' in value;
}
@@ -0,0 +1,93 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick } from 'vue';
import { usePreferredContrast } from '.';
function stubMatchMedia(matching: string): void {
vi.stubGlobal('matchMedia', vi.fn((media: string) => ({
matches: media.includes(matching),
media,
addEventListener: () => {},
removeEventListener: () => {},
})));
}
describe(usePreferredContrast, () => {
beforeEach(() => vi.stubGlobal('matchMedia', undefined));
afterEach(() => vi.unstubAllGlobals());
it.each([
['more'],
['less'],
['custom'],
['no-preference'],
] as const)('resolves the "%s" contrast preference', async (preference) => {
stubMatchMedia(`prefers-contrast: ${preference}`);
const scope = effectScope();
let contrast: ReturnType<typeof usePreferredContrast>;
scope.run(() => {
contrast = usePreferredContrast();
});
await nextTick();
expect(contrast!.value).toBe(preference);
scope.stop();
});
it('prioritizes "more" over the other preferences', async () => {
// Match everything; the resolution order must win.
vi.stubGlobal('matchMedia', vi.fn((media: string) => ({
matches: true,
media,
addEventListener: () => {},
removeEventListener: () => {},
})));
const scope = effectScope();
let contrast: ReturnType<typeof usePreferredContrast>;
scope.run(() => {
contrast = usePreferredContrast();
});
await nextTick();
expect(contrast!.value).toBe('more');
scope.stop();
});
it('falls back to "no-preference" when matchMedia is unsupported (SSR)', async () => {
// matchMedia is undefined (stubbed in beforeEach).
const scope = effectScope();
let contrast: ReturnType<typeof usePreferredContrast>;
scope.run(() => {
contrast = usePreferredContrast();
});
await nextTick();
expect(contrast!.value).toBe('no-preference');
scope.stop();
});
it('honors a custom ssrContrast fallback when unsupported', async () => {
const scope = effectScope();
let contrast: ReturnType<typeof usePreferredContrast>;
scope.run(() => {
contrast = usePreferredContrast({ ssrContrast: 'more' });
});
await nextTick();
expect(contrast!.value).toBe('more');
scope.stop();
});
it('returns "no-preference" with no window provided', async () => {
const scope = effectScope();
let contrast: ReturnType<typeof usePreferredContrast>;
scope.run(() => {
contrast = usePreferredContrast({ window: undefined });
});
await nextTick();
expect(contrast!.value).toBe('no-preference');
scope.stop();
});
});
@@ -0,0 +1,61 @@
import { computed } from 'vue';
import type { ComputedRef } from 'vue';
import { useMediaQuery } from '@/composables/browser/useMediaQuery';
import type { UseMediaQueryOptions } from '@/composables/browser/useMediaQuery';
export type ContrastType
= 'more' | 'less' | 'custom' | 'no-preference';
export interface UsePreferredContrastOptions extends UseMediaQueryOptions {
/**
* The contrast preference assumed during SSR (and the first client render),
* before `window.matchMedia` is available, to avoid hydration flicker.
*
* @default 'no-preference'
*/
ssrContrast?: ContrastType;
}
/**
* @name usePreferredContrast
* @category Browser
* @description Reactive `prefers-contrast` media query, resolving to the user's
* preferred contrast level. SSR-safe with an optional SSR fallback value.
*
* @param {UsePreferredContrastOptions} [options={}] Options (custom `window`, `ssrContrast`)
* @returns {ComputedRef<ContrastType>} Readonly ref of the preferred contrast: `'more' | 'less' | 'custom' | 'no-preference'`
*
* @example
* const contrast = usePreferredContrast();
*
* @example
* // Provide an SSR fallback to avoid hydration flicker
* const contrast = usePreferredContrast({ ssrContrast: 'more' });
*
* @since 0.0.15
*/
export function usePreferredContrast(
options: UsePreferredContrastOptions = {},
): ComputedRef<ContrastType> {
const { ssrContrast = 'no-preference', ...mediaOptions } = options;
const isMore = useMediaQuery('(prefers-contrast: more)', mediaOptions);
const isLess = useMediaQuery('(prefers-contrast: less)', mediaOptions);
const isCustom = useMediaQuery('(prefers-contrast: custom)', mediaOptions);
const isNoPreference = useMediaQuery('(prefers-contrast: no-preference)', mediaOptions);
return computed<ContrastType>(() => {
if (isMore.value)
return 'more';
if (isLess.value)
return 'less';
if (isCustom.value)
return 'custom';
// When no `prefers-contrast` query matches we're either on a browser that
// does not report a preference, or rendering on the server. Distinguish the
// explicit `no-preference` match from the unknown/SSR case via the fallback.
if (isNoPreference.value)
return 'no-preference';
return ssrContrast;
});
}
@@ -0,0 +1,131 @@
import { describe, expect, it, vi } from 'vitest';
import { effectScope } from 'vue';
import { usePreferredLanguages } from '.';
import type * as Types from '@/types';
type Listener = (event: Event) => void;
interface StubWindow {
navigator: { languages: readonly string[] };
addEventListener: (type: string, cb: Listener, options?: unknown) => void;
removeEventListener: (type: string, cb: Listener) => void;
dispatch: () => void;
listenerCount: () => number;
lastOptions: () => unknown;
}
/**
* Build a minimal stub `window` whose `navigator.languages` is mutable and that
* records its `languagechange` listeners so we can simulate the browser event.
*/
function makeWindow(initial: string[]): StubWindow {
const listeners = new Set<Listener>();
let languages: readonly string[] = initial;
let captured: unknown;
const stub: StubWindow = {
navigator: {
get languages() {
return languages;
},
},
addEventListener(type, cb, options) {
if (type === 'languagechange') {
listeners.add(cb);
captured = options;
}
},
removeEventListener(type, cb) {
if (type === 'languagechange') listeners.delete(cb);
},
dispatch() {
for (const cb of listeners) cb(new Event('languagechange'));
},
listenerCount: () => listeners.size,
lastOptions: () => captured,
};
// Allow tests to mutate languages before dispatching.
Object.defineProperty(stub, 'setLanguages', {
value: (next: string[]) => { languages = next; },
});
return stub;
}
describe(usePreferredLanguages, () => {
it('reads the initial navigator.languages', () => {
const win = makeWindow(['en-US', 'en']);
const scope = effectScope();
let langs: ReturnType<typeof usePreferredLanguages>;
scope.run(() => {
langs = usePreferredLanguages({ window: win as unknown as Window });
});
expect(langs!.value).toEqual(['en-US', 'en']);
scope.stop();
});
it('updates on languagechange', () => {
const win = makeWindow(['en']);
const scope = effectScope();
let langs: ReturnType<typeof usePreferredLanguages>;
scope.run(() => {
langs = usePreferredLanguages({ window: win as unknown as Window });
});
(win as unknown as { setLanguages: (n: string[]) => void }).setLanguages(['fr-FR', 'fr', 'en']);
win.dispatch();
expect(langs!.value).toEqual(['fr-FR', 'fr', 'en']);
scope.stop();
});
it('registers the listener with a passive option', () => {
const win = makeWindow(['en']);
const scope = effectScope();
scope.run(() => {
usePreferredLanguages({ window: win as unknown as Window });
});
expect(win.listenerCount()).toBe(1);
expect(win.lastOptions()).toEqual({ passive: true });
scope.stop();
});
it('removes the listener when the scope is disposed', () => {
const win = makeWindow(['en']);
const scope = effectScope();
scope.run(() => {
usePreferredLanguages({ window: win as unknown as Window });
});
expect(win.listenerCount()).toBe(1);
scope.stop();
expect(win.listenerCount()).toBe(0);
});
it('falls back to ["en"] when no window is available (SSR)', async () => {
// `defaultWindow` is import-time captured, so override it via a module mock
// to simulate a server environment where no window exists.
vi.resetModules();
vi.doMock('@/types', async () => {
const actual = await vi.importActual<typeof Types>('@/types');
return { ...actual, defaultWindow: undefined };
});
const { usePreferredLanguages: ssrUsePreferredLanguages } = await import('.');
const scope = effectScope();
let langs: ReturnType<typeof ssrUsePreferredLanguages>;
scope.run(() => {
langs = ssrUsePreferredLanguages();
});
expect(langs!.value).toEqual(['en']);
scope.stop();
vi.doUnmock('@/types');
vi.resetModules();
});
});
@@ -0,0 +1,43 @@
import type { ShallowRef } from 'vue';
import { shallowRef } from 'vue';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { useEventListener } from '@/composables/browser/useEventListener';
/**
* @name usePreferredLanguages
* @category Browser
* @description Reactive `navigator.languages`. Tracks the user's preferred languages and
* updates automatically whenever the browser emits a `languagechange` event.
*
* Falls back to `['en']` during SSR or when no `window` is available, so the returned
* value is always a non-empty array.
*
* @param {ConfigurableWindow} [options={}] Options
* @returns {ShallowRef<readonly string[]>} Reactive list of the user's preferred languages
*
* @example
* const languages = usePreferredLanguages();
* // -> ['en-US', 'en', 'fr']
*
* @example
* // Pass a custom window (e.g. an iframe)
* const languages = usePreferredLanguages({ window: iframe.contentWindow });
*
* @since 0.0.15
*/
export function usePreferredLanguages(options: ConfigurableWindow = {}): ShallowRef<readonly string[]> {
const { window = defaultWindow } = options;
if (!window)
return shallowRef<readonly string[]>(['en']);
const navigator = window.navigator;
const value = shallowRef<readonly string[]>(navigator.languages);
useEventListener(window, 'languagechange', () => {
value.value = navigator.languages;
}, { passive: true });
return value;
}
@@ -0,0 +1,101 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick } from 'vue';
import { usePreferredReducedMotion } from '.';
describe(usePreferredReducedMotion, () => {
beforeEach(() => vi.stubGlobal('matchMedia', undefined));
afterEach(() => vi.unstubAllGlobals());
it('resolves to "reduce" when the query matches', async () => {
vi.stubGlobal('matchMedia', vi.fn((media: string) => ({
matches: media.includes('reduce'),
media,
addEventListener: () => {},
removeEventListener: () => {},
})));
const scope = effectScope();
let motion: ReturnType<typeof usePreferredReducedMotion>;
scope.run(() => {
motion = usePreferredReducedMotion();
});
await nextTick();
expect(motion!.value).toBe('reduce');
scope.stop();
});
it('resolves to "no-preference" when the query does not match', async () => {
vi.stubGlobal('matchMedia', vi.fn((media: string) => ({
matches: false,
media,
addEventListener: () => {},
removeEventListener: () => {},
})));
const scope = effectScope();
let motion: ReturnType<typeof usePreferredReducedMotion>;
scope.run(() => {
motion = usePreferredReducedMotion();
});
await nextTick();
expect(motion!.value).toBe('no-preference');
scope.stop();
});
it('reacts to media query changes', async () => {
const listeners = new Set<(event: { matches: boolean }) => void>();
let currentMatches = false;
vi.stubGlobal('matchMedia', vi.fn((media: string) => ({
get matches() {
return currentMatches;
},
media,
addEventListener: (_: string, handler: (event: { matches: boolean }) => void) => listeners.add(handler),
removeEventListener: (_: string, handler: (event: { matches: boolean }) => void) => listeners.delete(handler),
})));
const scope = effectScope();
let motion: ReturnType<typeof usePreferredReducedMotion>;
scope.run(() => {
motion = usePreferredReducedMotion();
});
await nextTick();
expect(motion!.value).toBe('no-preference');
currentMatches = true;
listeners.forEach(handler => handler({ matches: true }));
await nextTick();
expect(motion!.value).toBe('reduce');
scope.stop();
});
it('falls back to "no-preference" when matchMedia is unsupported (SSR)', async () => {
// matchMedia is left undefined from beforeEach.
const scope = effectScope();
let motion: ReturnType<typeof usePreferredReducedMotion>;
scope.run(() => {
motion = usePreferredReducedMotion();
});
await nextTick();
expect(motion!.value).toBe('no-preference');
scope.stop();
});
it('returns "no-preference" when window is explicitly undefined (SSR)', async () => {
const scope = effectScope();
let motion: ReturnType<typeof usePreferredReducedMotion>;
scope.run(() => {
motion = usePreferredReducedMotion({ window: undefined });
});
await nextTick();
expect(motion!.value).toBe('no-preference');
scope.stop();
});
});
@@ -0,0 +1,41 @@
import { computed } from 'vue';
import type { ComputedRef } from 'vue';
import { useMediaQuery } from '@/composables/browser/useMediaQuery';
import type { UseMediaQueryOptions } from '@/composables/browser/useMediaQuery';
export type ReducedMotionType
= | 'reduce'
| 'no-preference';
export type UsePreferredReducedMotionOptions = UseMediaQueryOptions;
export type UsePreferredReducedMotionReturn = ComputedRef<ReducedMotionType>;
/**
* @name usePreferredReducedMotion
* @category Browser
* @description Reactive `prefers-reduced-motion` media query, resolving to
* `'reduce'` when the user requests reduced motion and `'no-preference'`
* otherwise. SSR-safe via {@link useMediaQuery}.
*
* @param {UsePreferredReducedMotionOptions} [options={}] Options (custom `window`)
* @returns {UsePreferredReducedMotionReturn} Readonly ref of the current motion preference
*
* @example
* const motion = usePreferredReducedMotion();
* // motion.value === 'reduce' | 'no-preference'
*
* @example
* watchEffect(() => {
* transitionDuration.value = motion.value === 'reduce' ? 0 : 200;
* });
*
* @since 0.0.15
*/
export function usePreferredReducedMotion(
options: UsePreferredReducedMotionOptions = {},
): UsePreferredReducedMotionReturn {
const isReduced = useMediaQuery('(prefers-reduced-motion: reduce)', options);
return computed<ReducedMotionType>(() => isReduced.value ? 'reduce' : 'no-preference');
}
@@ -0,0 +1,58 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick } from 'vue';
import { usePreferredReducedTransparency } from '.';
describe(usePreferredReducedTransparency, () => {
beforeEach(() => vi.stubGlobal('matchMedia', undefined));
afterEach(() => vi.unstubAllGlobals());
it('resolves to "reduce" when the media query matches', async () => {
vi.stubGlobal('matchMedia', vi.fn((media: string) => ({
matches: media.includes('reduce'),
media,
addEventListener: () => {},
removeEventListener: () => {},
})));
const scope = effectScope();
let transparency: ReturnType<typeof usePreferredReducedTransparency>;
scope.run(() => {
transparency = usePreferredReducedTransparency();
});
await nextTick();
expect(transparency!.value).toBe('reduce');
scope.stop();
});
it('resolves to "no-preference" when the media query does not match', async () => {
vi.stubGlobal('matchMedia', vi.fn((media: string) => ({
matches: false,
media,
addEventListener: () => {},
removeEventListener: () => {},
})));
const scope = effectScope();
let transparency: ReturnType<typeof usePreferredReducedTransparency>;
scope.run(() => {
transparency = usePreferredReducedTransparency();
});
await nextTick();
expect(transparency!.value).toBe('no-preference');
scope.stop();
});
it('defaults to "no-preference" when matchMedia is unsupported (SSR)', async () => {
const scope = effectScope();
let transparency: ReturnType<typeof usePreferredReducedTransparency>;
scope.run(() => {
transparency = usePreferredReducedTransparency({ window: undefined });
});
await nextTick();
expect(transparency!.value).toBe('no-preference');
scope.stop();
});
});
@@ -0,0 +1,30 @@
import { computed } from 'vue';
import type { ComputedRef } from 'vue';
import type { ConfigurableWindow } from '@/types';
import { useMediaQuery } from '@/composables/browser/useMediaQuery';
export type ReducedTransparencyType
= 'reduce' | 'no-preference';
/**
* @name usePreferredReducedTransparency
* @category Browser
* @description Reactive `prefers-reduced-transparency` media query, resolving to
* `'reduce'` or `'no-preference'`. SSR-safe (defaults to `'no-preference'`).
*
* @param {ConfigurableWindow} [options={}] Options (custom `window`)
* @returns {ComputedRef<ReducedTransparencyType>} Readonly ref of the user's transparency preference
*
* @example
* const transparency = usePreferredReducedTransparency();
* // transparency.value === 'reduce' | 'no-preference'
*
* @since 0.0.15
*/
export function usePreferredReducedTransparency(
options: ConfigurableWindow = {},
): ComputedRef<ReducedTransparencyType> {
const isReduced = useMediaQuery('(prefers-reduced-transparency: reduce)', options);
return computed<ReducedTransparencyType>(() => isReduced.value ? 'reduce' : 'no-preference');
}
@@ -1,252 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { defineComponent, effectScope, nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import { useRafFn } from '.';
let rafCallbacks: Array<(time: number) => void> = [];
let rafIdCounter = 0;
let currentTime = 0;
beforeEach(() => {
rafCallbacks = [];
rafIdCounter = 0;
currentTime = 0;
vi.stubGlobal('requestAnimationFrame', (cb: (time: number) => void) => {
const id = ++rafIdCounter;
rafCallbacks.push(cb);
return id;
});
vi.stubGlobal('cancelAnimationFrame', vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
});
function triggerFrame(time: number) {
currentTime = time;
const cbs = [...rafCallbacks];
rafCallbacks = [];
cbs.forEach(cb => cb(currentTime));
}
const ComponentStub = defineComponent({
props: {
callback: {
type: Function,
required: true,
},
options: {
type: Object,
default: () => ({}),
},
},
setup(props) {
const result = useRafFn(props.callback as any, props.options);
return { ...result };
},
template: '<div>{{ isActive }}</div>',
});
describe(useRafFn, () => {
it('starts immediately by default', () => {
const callback = vi.fn();
const wrapper = mount(ComponentStub, {
props: { callback },
});
expect(wrapper.text()).toBe('true');
});
it('does not start when immediate is false', () => {
const callback = vi.fn();
const wrapper = mount(ComponentStub, {
props: {
callback,
options: { immediate: false },
},
});
expect(wrapper.text()).toBe('false');
expect(callback).not.toHaveBeenCalled();
});
it('calls the callback on animation frame with delta and timestamp', () => {
const callback = vi.fn();
mount(ComponentStub, {
props: { callback },
});
triggerFrame(100);
expect(callback).toHaveBeenCalledWith({ delta: 0, timestamp: 100 });
});
it('provides correct delta between frames', () => {
const callback = vi.fn();
mount(ComponentStub, {
props: { callback },
});
triggerFrame(100);
triggerFrame(116.67);
expect(callback).toHaveBeenCalledTimes(2);
expect(callback.mock.calls[1]![0]!.delta).toBeCloseTo(16.67, 1);
});
it('pauses and resumes the loop', async () => {
const callback = vi.fn();
const wrapper = mount(ComponentStub, {
props: { callback },
});
triggerFrame(100);
expect(callback).toHaveBeenCalledTimes(1);
wrapper.vm.pause();
await nextTick();
expect(wrapper.text()).toBe('false');
triggerFrame(200);
expect(callback).toHaveBeenCalledTimes(1);
wrapper.vm.resume();
await nextTick();
expect(wrapper.text()).toBe('true');
triggerFrame(300);
expect(callback).toHaveBeenCalledTimes(2);
});
it('resets delta after resume', () => {
const callback = vi.fn();
const wrapper = mount(ComponentStub, {
props: { callback },
});
triggerFrame(100);
wrapper.vm.pause();
wrapper.vm.resume();
triggerFrame(500);
// After resume, first frame delta resets to 0
const lastCall = callback.mock.calls[callback.mock.calls.length - 1]![0]!;
expect(lastCall.delta).toBe(0);
expect(lastCall.timestamp).toBe(500);
});
it('toggles the loop', async () => {
const callback = vi.fn();
const wrapper = mount(ComponentStub, {
props: { callback },
});
expect(wrapper.text()).toBe('true');
wrapper.vm.toggle();
await nextTick();
expect(wrapper.text()).toBe('false');
wrapper.vm.toggle();
await nextTick();
expect(wrapper.text()).toBe('true');
});
it('limits frame rate with fpsLimit', () => {
const callback = vi.fn();
mount(ComponentStub, {
props: {
callback,
options: { fpsLimit: 30 },
},
});
// First frame always fires (delta is 0)
triggerFrame(100);
expect(callback).toHaveBeenCalledTimes(1);
// 30fps = ~33.33ms per frame — too soon, skipped
triggerFrame(110);
expect(callback).toHaveBeenCalledTimes(1);
// Enough time passed (~40ms > 33.33ms)
triggerFrame(140);
expect(callback).toHaveBeenCalledTimes(2);
});
it('cleans up on scope dispose', () => {
const callback = vi.fn();
const scope = effectScope();
scope.run(() => {
useRafFn(callback);
});
triggerFrame(100);
expect(callback).toHaveBeenCalledTimes(1);
scope.stop();
triggerFrame(200);
expect(callback).toHaveBeenCalledTimes(1);
});
it('cleans up on component unmount', () => {
const callback = vi.fn();
const wrapper = mount(ComponentStub, {
props: { callback },
});
triggerFrame(100);
expect(callback).toHaveBeenCalledTimes(1);
wrapper.unmount();
triggerFrame(200);
expect(callback).toHaveBeenCalledTimes(1);
});
it('does nothing when window is undefined (SSR)', () => {
const callback = vi.fn();
const scope = effectScope();
scope.run(() => {
const { isActive } = useRafFn(callback, { window: undefined as any });
expect(isActive.value).toBeFalsy();
});
expect(callback).not.toHaveBeenCalled();
scope.stop();
});
it('resume is idempotent when already active', () => {
const scope = effectScope();
let result: ReturnType<typeof useRafFn>;
scope.run(() => {
result = useRafFn(vi.fn());
});
expect(result!.isActive.value).toBeTruthy();
result!.resume();
expect(result!.isActive.value).toBeTruthy();
scope.stop();
});
it('pause is idempotent when already paused', () => {
const scope = effectScope();
let result: ReturnType<typeof useRafFn>;
scope.run(() => {
result = useRafFn(vi.fn(), { immediate: false });
});
expect(result!.isActive.value).toBeFalsy();
result!.pause();
expect(result!.isActive.value).toBeFalsy();
scope.stop();
});
});
@@ -1,120 +0,0 @@
import { readonly, ref } from 'vue';
import type { Ref } from 'vue';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow, ResumableActions, ResumableOptions } from '@/types';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
export interface UseRafFnCallbackArgs {
/**
* Time elapsed since the last frame in milliseconds
*/
delta: number;
/**
* `DOMHighResTimeStamp` passed by `requestAnimationFrame`
*/
timestamp: DOMHighResTimeStamp;
}
export interface UseRafFnOptions extends ResumableOptions, ConfigurableWindow {
/**
* Maximum frames per second. Set to `0` or `undefined` to disable the limit.
*
* @default undefined
*/
fpsLimit?: number;
}
export interface UseRafFnReturn extends ResumableActions {
/**
* Whether the RAF loop is currently active
*/
isActive: Readonly<Ref<boolean>>;
}
/**
* Call a function on every `requestAnimationFrame` with delta time tracking.
* Automatically cleans up when the component scope is disposed.
*
* @param callback - Function to call on every animation frame
* @param options - Configuration options
*
* @example
* ```ts
* const { pause, resume, isActive } = useRafFn(({ delta, timestamp }) => {
* console.log(`${delta}ms since last frame`);
* });
* ```
*/
export function useRafFn(
callback: (args: UseRafFnCallbackArgs) => void,
options: UseRafFnOptions = {},
): UseRafFnReturn {
const {
immediate = true,
fpsLimit,
} = options;
const window = 'window' in options ? options.window : defaultWindow;
const isActive = ref(false);
const intervalLimit = fpsLimit ? 1000 / fpsLimit : null;
let previousFrameTimestamp = 0;
let rafId: number | null = null;
function loop(timestamp: DOMHighResTimeStamp) {
if (!isActive.value || !window)
return;
if (!previousFrameTimestamp)
previousFrameTimestamp = timestamp;
const delta = timestamp - previousFrameTimestamp;
if (intervalLimit && delta && delta < intervalLimit) {
rafId = window.requestAnimationFrame(loop);
return;
}
previousFrameTimestamp = timestamp;
callback({ delta, timestamp });
rafId = window.requestAnimationFrame(loop);
}
function resume() {
if (!isActive.value && window) {
isActive.value = true;
previousFrameTimestamp = 0;
rafId = window.requestAnimationFrame(loop);
}
}
function pause() {
isActive.value = false;
if (rafId !== null && window) {
window.cancelAnimationFrame(rafId);
rafId = null;
}
}
function toggle() {
if (isActive.value)
pause();
else
resume();
}
if (immediate)
resume();
tryOnScopeDispose(pause);
return {
isActive: readonly(isActive),
pause,
resume,
toggle,
};
}
@@ -1,188 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, ref } from 'vue';
import { useResizeObserver } from '.';
let instances: Array<{ cb: ResizeObserverCallback; observe: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn> }> = [];
class StubResizeObserver {
observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
cb: ResizeObserverCallback;
constructor(cb: ResizeObserverCallback) {
this.cb = cb;
instances.push(this);
}
}
describe(useResizeObserver, () => {
beforeEach(() => {
instances = [];
vi.stubGlobal('ResizeObserver', StubResizeObserver);
});
afterEach(() => vi.unstubAllGlobals());
it('observes the target element', () => {
const el = document.createElement('div');
const scope = effectScope();
scope.run(() => useResizeObserver(ref(el), vi.fn()));
expect(instances).toHaveLength(1);
expect(instances[0]!.observe).toHaveBeenCalledWith(el, undefined);
scope.stop();
});
it('passes the box option through to observe', () => {
const el = document.createElement('div');
const scope = effectScope();
scope.run(() => useResizeObserver(ref(el), vi.fn(), { box: 'border-box' }));
expect(instances[0]!.observe).toHaveBeenCalledWith(el, { box: 'border-box' });
scope.stop();
});
it('observes an array of targets with a single observer', () => {
const a = document.createElement('div');
const b = document.createElement('div');
const scope = effectScope();
scope.run(() => useResizeObserver([ref(a), ref(b)], vi.fn()));
expect(instances).toHaveLength(1);
expect(instances[0]!.observe).toHaveBeenCalledWith(a, undefined);
expect(instances[0]!.observe).toHaveBeenCalledWith(b, undefined);
scope.stop();
});
it('supports a getter target', () => {
const el = document.createElement('div');
const scope = effectScope();
scope.run(() => useResizeObserver(() => el, vi.fn()));
expect(instances).toHaveLength(1);
expect(instances[0]!.observe).toHaveBeenCalledWith(el, undefined);
scope.stop();
});
it('disconnects on stop', () => {
const el = document.createElement('div');
const scope = effectScope();
let stop: () => void;
scope.run(() => {
stop = useResizeObserver(ref(el), vi.fn()).stop;
});
stop!();
expect(instances[0]!.disconnect).toHaveBeenCalled();
scope.stop();
});
it('invokes the callback with entries', () => {
const el = document.createElement('div');
const callback = vi.fn();
const scope = effectScope();
scope.run(() => useResizeObserver(ref(el), callback));
const entry = { contentRect: { width: 10, height: 20 } } as ResizeObserverEntry;
instances[0]!.cb([entry], instances[0] as unknown as ResizeObserver);
expect(callback).toHaveBeenCalledWith([entry], expect.anything());
scope.stop();
});
it('re-observes when the target ref changes', async () => {
const a = document.createElement('div');
const b = document.createElement('div');
const target = ref<HTMLElement>(a);
const scope = effectScope();
scope.run(() => useResizeObserver(target, vi.fn()));
expect(instances).toHaveLength(1);
expect(instances[0]!.observe).toHaveBeenCalledWith(a, undefined);
target.value = b;
await nextTick();
expect(instances[0]!.disconnect).toHaveBeenCalled();
expect(instances).toHaveLength(2);
expect(instances[1]!.observe).toHaveBeenCalledWith(b, undefined);
scope.stop();
});
it('does not create an observer for a null target', () => {
const target = ref<HTMLElement | null>(null);
const scope = effectScope();
scope.run(() => useResizeObserver(target, vi.fn()));
expect(instances).toHaveLength(0);
scope.stop();
});
it('starts observing when a null target is later assigned', async () => {
const el = document.createElement('div');
const target = ref<HTMLElement | null>(null);
const scope = effectScope();
scope.run(() => useResizeObserver(target, vi.fn()));
expect(instances).toHaveLength(0);
target.value = el;
await nextTick();
expect(instances).toHaveLength(1);
expect(instances[0]!.observe).toHaveBeenCalledWith(el, undefined);
scope.stop();
});
it('does not observe when immediate is false until resumed', async () => {
const el = document.createElement('div');
const scope = effectScope();
let controls!: ReturnType<typeof useResizeObserver>;
scope.run(() => {
controls = useResizeObserver(ref(el), vi.fn(), { immediate: false });
});
expect(controls.isActive.value).toBeFalsy();
expect(instances).toHaveLength(0);
controls.resume();
await nextTick();
expect(controls.isActive.value).toBeTruthy();
expect(instances).toHaveLength(1);
expect(instances[0]!.observe).toHaveBeenCalledWith(el, undefined);
scope.stop();
});
it('pause disconnects and flips isActive, resume re-observes', async () => {
const el = document.createElement('div');
const scope = effectScope();
let controls!: ReturnType<typeof useResizeObserver>;
scope.run(() => {
controls = useResizeObserver(ref(el), vi.fn());
});
expect(controls.isActive.value).toBeTruthy();
expect(instances).toHaveLength(1);
controls.pause();
expect(controls.isActive.value).toBeFalsy();
expect(instances[0]!.disconnect).toHaveBeenCalled();
controls.resume();
await nextTick();
expect(controls.isActive.value).toBeTruthy();
expect(instances).toHaveLength(2);
expect(instances[1]!.observe).toHaveBeenCalledWith(el, undefined);
scope.stop();
});
it('cleans up when the scope is disposed', () => {
const el = document.createElement('div');
const scope = effectScope();
scope.run(() => useResizeObserver(ref(el), vi.fn()));
expect(instances).toHaveLength(1);
scope.stop();
expect(instances[0]!.disconnect).toHaveBeenCalled();
});
});
@@ -1,149 +0,0 @@
import { computed, readonly, ref, watch } from 'vue';
import type { Ref } from 'vue';
import { toArray } from '@robonen/stdlib';
import type { ConfigurableWindow } from '@/types';
import { defaultWindow } from '@/types';
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
import { unrefElement } from '@/composables/component/unrefElement';
import { useSupported } from '@/composables/browser/useSupported';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
export interface UseResizeObserverOptions extends ConfigurableWindow {
/**
* The box model to observe
*
* @default 'content-box'
*/
box?: ResizeObserverBoxOptions;
/**
* Start observing immediately once the target is resolved
*
* @default true
*/
immediate?: boolean;
}
export type ResizeObserverCallback = (
entries: readonly ResizeObserverEntry[],
observer: ResizeObserver,
) => void;
export interface UseResizeObserverReturn {
/**
* Whether `ResizeObserver` is supported in the current environment
*/
isSupported: Readonly<Ref<boolean>>;
/**
* Whether the observer is currently active
*/
isActive: Readonly<Ref<boolean>>;
/**
* Temporarily stop observing (disconnects the observer) while keeping the
* target watcher alive, so observing can be resumed later
*/
pause: () => void;
/**
* Resume observing after a `pause`
*/
resume: () => void;
/**
* Permanently stop observing and tear down the target watcher
*/
stop: () => void;
}
/**
* @name useResizeObserver
* @category Browser
* @description Reports changes to the dimensions of an element via `ResizeObserver`.
* Accepts a single target or an array of (reactive) targets. The observer is
* recreated only when the resolved elements change, and can be paused/resumed.
*
* @param {MaybeComputedElementRef | MaybeComputedElementRef[]} target Element(s) to observe
* @param {ResizeObserverCallback} callback Invoked with the observer entries
* @param {UseResizeObserverOptions} [options={}] Options
* @returns {UseResizeObserverReturn} `isSupported`, `isActive`, `pause`, `resume`, and `stop`
*
* @example
* useResizeObserver(el, ([entry]) => {
* console.log(entry.contentRect.width);
* });
*
* @example
* const { pause, resume } = useResizeObserver([el1, el2], (entries) => {
* // react to multiple targets
* }, { box: 'border-box' });
*
* @since 0.0.15
*/
export function useResizeObserver(
target: MaybeComputedElementRef | MaybeComputedElementRef[],
callback: ResizeObserverCallback,
options: UseResizeObserverOptions = {},
): UseResizeObserverReturn {
const { window = defaultWindow, box, immediate = true } = options;
const isSupported = useSupported(() => window && 'ResizeObserver' in window);
// Cache the observer options object so it is not rebuilt on every observe call
const observerOptions: ResizeObserverOptions | undefined = box ? { box } : undefined;
const isActive = ref(immediate);
let observer: ResizeObserver | undefined;
const targets = computed(() => {
return toArray(target).map(el => unrefElement(el)).filter((el): el is Element => Boolean(el));
});
const cleanup = () => {
if (observer) {
observer.disconnect();
observer = undefined;
}
};
const stopWatch = watch(
() => [targets.value, isActive.value] as const,
([els, active]) => {
cleanup();
if (!active || !isSupported.value || !window || !els.length)
return;
observer = new ResizeObserver(callback);
for (const el of els)
observer.observe(el, observerOptions);
},
{ immediate: true, flush: 'post' },
);
const resume = () => {
isActive.value = true;
};
const pause = () => {
cleanup();
isActive.value = false;
};
const stop = () => {
cleanup();
stopWatch();
};
tryOnScopeDispose(stop);
return {
isSupported,
isActive: readonly(isActive),
pause,
resume,
stop,
};
}
@@ -1,188 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick } from 'vue';
import { useScreenOrientation } from '.';
import type { OrientationLockType, OrientationType } from '.';
type Listener = (event: Event) => void;
interface StubScreenOrientation {
type: OrientationType;
angle: number;
lock: ReturnType<typeof vi.fn>;
unlock: ReturnType<typeof vi.fn>;
addEventListener: (type: string, cb: Listener) => void;
removeEventListener: (type: string, cb: Listener) => void;
dispatch: (type: OrientationType, angle: number) => void;
}
function makeScreenOrientation(
type: OrientationType = 'portrait-primary',
angle = 0,
): StubScreenOrientation {
const listeners = new Set<Listener>();
const so: StubScreenOrientation = {
type,
angle,
lock: vi.fn(() => Promise.resolve()),
unlock: vi.fn(),
addEventListener: (_: string, cb: Listener) => listeners.add(cb),
removeEventListener: (_: string, cb: Listener) => listeners.delete(cb),
dispatch(nextType: OrientationType, nextAngle: number) {
so.type = nextType;
so.angle = nextAngle;
for (const cb of listeners) cb(new Event('change'));
},
};
return so;
}
/** A window stub exposing `screen.orientation` + an event target for `orientationchange`. */
function makeWindowStub(so?: StubScreenOrientation): Window {
const winListeners = new Set<Listener>();
return {
screen: so ? { orientation: so } : {},
addEventListener: (_: string, cb: Listener) => winListeners.add(cb),
removeEventListener: (_: string, cb: Listener) => winListeners.delete(cb),
} as unknown as Window;
}
describe(useScreenOrientation, () => {
beforeEach(() => {
// Force the SSR / unsupported branch unless a window is passed via options.
vi.stubGlobal('screen', undefined);
});
afterEach(() => vi.unstubAllGlobals());
it('reports supported when screen.orientation is present', async () => {
const window = makeWindowStub(makeScreenOrientation());
const scope = effectScope();
let result: ReturnType<typeof useScreenOrientation>;
scope.run(() => {
result = useScreenOrientation({ window });
});
await nextTick();
expect(result!.isSupported.value).toBeTruthy();
scope.stop();
});
it('reflects the initial orientation type and angle', async () => {
const so = makeScreenOrientation('landscape-primary', 90);
const window = makeWindowStub(so);
const scope = effectScope();
let result: ReturnType<typeof useScreenOrientation>;
scope.run(() => {
result = useScreenOrientation({ window });
});
await nextTick();
expect(result!.orientation.value).toBe('landscape-primary');
expect(result!.angle.value).toBe(90);
scope.stop();
});
it('updates on the screen.orientation change event', async () => {
const so = makeScreenOrientation('portrait-primary', 0);
const window = makeWindowStub(so);
const scope = effectScope();
let result: ReturnType<typeof useScreenOrientation>;
scope.run(() => {
result = useScreenOrientation({ window });
});
await nextTick();
expect(result!.orientation.value).toBe('portrait-primary');
so.dispatch('landscape-secondary', 270);
await nextTick();
expect(result!.orientation.value).toBe('landscape-secondary');
expect(result!.angle.value).toBe(270);
scope.stop();
});
it('lockOrientation delegates to screen.orientation.lock', async () => {
const so = makeScreenOrientation();
const window = makeWindowStub(so);
const scope = effectScope();
let result: ReturnType<typeof useScreenOrientation>;
scope.run(() => {
result = useScreenOrientation({ window });
});
await nextTick();
const type: OrientationLockType = 'landscape';
await expect(result!.lockOrientation(type)).resolves.toBeUndefined();
expect(so.lock).toHaveBeenCalledWith('landscape');
scope.stop();
});
it('unlockOrientation delegates to screen.orientation.unlock', async () => {
const so = makeScreenOrientation();
const window = makeWindowStub(so);
const scope = effectScope();
let result: ReturnType<typeof useScreenOrientation>;
scope.run(() => {
result = useScreenOrientation({ window });
});
await nextTick();
result!.unlockOrientation();
expect(so.unlock).toHaveBeenCalledTimes(1);
scope.stop();
});
it('stops updating after the scope is disposed', async () => {
const so = makeScreenOrientation('portrait-primary', 0);
const window = makeWindowStub(so);
const scope = effectScope();
let result: ReturnType<typeof useScreenOrientation>;
scope.run(() => {
result = useScreenOrientation({ window });
});
await nextTick();
scope.stop();
so.dispatch('landscape-primary', 90);
await nextTick();
// Listener removed on dispose, so the value should not have changed.
expect(result!.orientation.value).toBe('portrait-primary');
expect(result!.angle.value).toBe(0);
});
it('is unsupported and safe when no window is available (SSR)', async () => {
// Pass an explicit falsy window: a destructuring default only fires for
// `undefined`, so `null` lets us exercise the genuine no-window branch.
const scope = effectScope();
let result: ReturnType<typeof useScreenOrientation>;
scope.run(() => {
result = useScreenOrientation({ window: null as unknown as Window });
});
await nextTick();
expect(result!.isSupported.value).toBeFalsy();
expect(result!.orientation.value).toBeUndefined();
expect(result!.angle.value).toBe(0);
expect(() => result!.unlockOrientation()).not.toThrow();
await expect(result!.lockOrientation('any')).rejects.toThrow('Not supported');
scope.stop();
});
it('is unsupported when screen lacks orientation', async () => {
const window = makeWindowStub();
const scope = effectScope();
let result: ReturnType<typeof useScreenOrientation>;
scope.run(() => {
result = useScreenOrientation({ window });
});
await nextTick();
expect(result!.isSupported.value).toBeFalsy();
await expect(result!.lockOrientation('portrait')).rejects.toThrow('Not supported');
scope.stop();
});
});
@@ -1,121 +0,0 @@
import { shallowRef } from 'vue';
import type { ComputedRef, ShallowRef } from 'vue';
import { isFunction } from '@robonen/stdlib';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { useEventListener } from '@/composables/browser/useEventListener';
import { useSupported } from '@/composables/browser/useSupported';
export type OrientationType
= | 'portrait-primary'
| 'portrait-secondary'
| 'landscape-primary'
| 'landscape-secondary';
export type OrientationLockType
= | 'any'
| 'natural'
| 'landscape'
| 'portrait'
| 'portrait-primary'
| 'portrait-secondary'
| 'landscape-primary'
| 'landscape-secondary';
/**
* Subset of the `ScreenOrientation` interface that we interact with.
*/
export interface ScreenOrientation extends EventTarget {
readonly type: OrientationType;
readonly angle: number;
lock: (orientation: OrientationLockType) => Promise<void>;
unlock: () => void;
}
export interface UseScreenOrientationOptions extends ConfigurableWindow {}
export interface UseScreenOrientationReturn {
/**
* Whether the Screen Orientation API is supported
*/
isSupported: ComputedRef<boolean>;
/**
* Current screen orientation type, or `undefined` when unsupported
*/
orientation: ShallowRef<OrientationType | undefined>;
/**
* Current screen orientation angle in degrees (defaults to `0`)
*/
angle: ShallowRef<number>;
/**
* Lock the screen to the given orientation. Rejects when unsupported.
*/
lockOrientation: (type: OrientationLockType) => Promise<void>;
/**
* Release a previously applied orientation lock. No-op when unsupported.
*/
unlockOrientation: () => void;
}
/**
* @name useScreenOrientation
* @category Browser
* @description Reactive Screen Orientation API. Tracks the current orientation
* `type` and `angle`, and exposes helpers to lock/unlock the orientation. SSR-safe.
*
* @param {UseScreenOrientationOptions} [options={}] Options (custom `window`)
* @returns {UseScreenOrientationReturn} `{ isSupported, orientation, angle, lockOrientation, unlockOrientation }`
*
* @example
* const { isSupported, orientation, angle, lockOrientation, unlockOrientation } = useScreenOrientation();
*
* @example
* // Lock to landscape (must run from a user gesture / fullscreen context)
* const { lockOrientation } = useScreenOrientation();
* await lockOrientation('landscape');
*
* @since 0.0.15
*/
export function useScreenOrientation(options: UseScreenOrientationOptions = {}): UseScreenOrientationReturn {
const { window = defaultWindow } = options;
const isSupported = useSupported(() =>
Boolean(window && 'screen' in window && window.screen && 'orientation' in window.screen));
const screenOrientation = (isSupported.value ? window!.screen.orientation : {}) as ScreenOrientation;
const orientation = shallowRef<OrientationType | undefined>(screenOrientation.type);
const angle = shallowRef(screenOrientation.angle || 0);
const update = (): void => {
orientation.value = screenOrientation.type;
angle.value = screenOrientation.angle;
};
if (isSupported.value) {
// The standard `change` event fires on the `ScreenOrientation` object itself;
// `orientationchange` on `window` is the legacy fallback for older engines.
useEventListener(screenOrientation, 'change', update, { passive: true });
useEventListener(window, 'orientationchange', update, { passive: true });
}
const lockOrientation = (type: OrientationLockType): Promise<void> => {
if (isSupported.value && isFunction(screenOrientation.lock))
return screenOrientation.lock(type);
return Promise.reject(new Error('Not supported'));
};
const unlockOrientation = (): void => {
if (isSupported.value && isFunction(screenOrientation.unlock))
screenOrientation.unlock();
};
return {
isSupported,
orientation,
angle,
lockOrientation,
unlockOrientation,
};
}

Some files were not shown because too many files have changed in this diff Show More