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,17 @@
export * from './onElementRemoval';
export * from './useActiveElement';
export * from './useDocumentReadyState';
export * from './useDocumentVisibility';
export * from './useDraggable';
export * from './useDropZone';
export * from './useElementBounding';
export * from './useElementSize';
export * from './useElementVisibility';
export * from './useFocusGuard';
export * from './useIntersectionObserver';
export * from './useMutationObserver';
export * from './useParentElement';
export * from './useResizeObserver';
export * from './useWindowFocus';
export * from './useWindowScroll';
export * from './useWindowSize';
@@ -0,0 +1,157 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, ref } from 'vue';
import { onElementRemoval } from '.';
let instances: 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;
instances.push(this);
}
}
function fireRemoval(index: number, removedNodes: Node[]): void {
const record = { removedNodes } as unknown as MutationRecord;
instances[index]!.cb([record], instances[index] as unknown as MutationObserver);
}
describe(onElementRemoval, () => {
beforeEach(() => {
instances = [];
vi.stubGlobal('MutationObserver', StubMutationObserver);
});
afterEach(() => vi.unstubAllGlobals());
it('observes the document subtree once a target is available', () => {
const el = document.createElement('div');
const scope = effectScope();
scope.run(() => onElementRemoval(ref(el), vi.fn()));
expect(instances).toHaveLength(1);
expect(instances[0]!.observe).toHaveBeenCalledWith(
document.documentElement,
{ childList: true, subtree: true },
);
scope.stop();
});
it('fires the callback when the element itself is removed', () => {
const el = document.createElement('div');
const callback = vi.fn();
const scope = effectScope();
scope.run(() => onElementRemoval(el, callback));
fireRemoval(0, [el]);
expect(callback).toHaveBeenCalledTimes(1);
scope.stop();
});
it('fires when an ancestor containing the element is removed', () => {
const parent = document.createElement('div');
const el = document.createElement('span');
parent.appendChild(el);
const callback = vi.fn();
const scope = effectScope();
scope.run(() => onElementRemoval(el, callback));
fireRemoval(0, [parent]);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith([{ removedNodes: [parent] }]);
scope.stop();
});
it('does not fire when an unrelated node is removed', () => {
const el = document.createElement('div');
const other = document.createElement('div');
const callback = vi.fn();
const scope = effectScope();
scope.run(() => onElementRemoval(el, callback));
fireRemoval(0, [other]);
expect(callback).not.toHaveBeenCalled();
scope.stop();
});
it('does not observe for a nullish target', () => {
const callback = vi.fn();
const scope = effectScope();
scope.run(() => onElementRemoval(ref(null), callback));
expect(instances).toHaveLength(0);
scope.stop();
});
it('re-observes when a reactive target appears, and tears down the old observer', async () => {
const target = ref<HTMLElement | null>(null);
const scope = effectScope();
scope.run(() => onElementRemoval(target, vi.fn(), { flush: 'sync' }));
expect(instances).toHaveLength(0);
target.value = document.createElement('div');
await nextTick();
expect(instances).toHaveLength(1);
target.value = document.createElement('span');
await nextTick();
expect(instances).toHaveLength(2);
expect(instances[0]!.disconnect).toHaveBeenCalled();
scope.stop();
});
it('stop() disconnects and prevents further callbacks', () => {
const el = document.createElement('div');
const callback = vi.fn();
const scope = effectScope();
let stop: () => void;
scope.run(() => {
stop = onElementRemoval(el, callback);
});
stop!();
expect(instances[0]!.disconnect).toHaveBeenCalled();
scope.stop();
});
it('disposes the observer when the scope stops', () => {
const el = document.createElement('div');
const scope = effectScope();
scope.run(() => onElementRemoval(el, vi.fn()));
expect(instances).toHaveLength(1);
scope.stop();
expect(instances[0]!.disconnect).toHaveBeenCalled();
});
it('supports post flush timing', async () => {
const el = document.createElement('div');
const scope = effectScope();
scope.run(() => onElementRemoval(ref(el), vi.fn(), { flush: 'post' }));
await nextTick();
expect(instances).toHaveLength(1);
scope.stop();
});
it('returns a no-op and does not observe when document is unavailable (SSR)', () => {
const el = document.createElement('div');
const callback = vi.fn();
const scope = effectScope();
let stop: () => void;
scope.run(() => {
stop = onElementRemoval(el, callback, {
window: { document: undefined } as unknown as Window & typeof globalThis,
});
});
expect(instances).toHaveLength(0);
expect(callback).not.toHaveBeenCalled();
expect(() => stop!()).not.toThrow();
scope.stop();
});
});
@@ -0,0 +1,99 @@
import { watch } from 'vue';
import { noop } from '@robonen/stdlib';
import type { VoidFunction } from '@robonen/stdlib';
import type { ConfigurableDocument, ConfigurableFlush, 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 OnElementRemovalOptions extends ConfigurableWindow, ConfigurableDocument, ConfigurableFlush {}
export type OnElementRemovalCallback = (mutationRecords: MutationRecord[]) => void;
export type OnElementRemovalReturn = VoidFunction;
/**
* @name onElementRemoval
* @category Elements
* @description Fire a callback when the target element — or any ancestor containing it — is
* removed from the DOM. Backed by a single `childList`/`subtree` `MutationObserver` on the
* element's owning document, so it also catches removal of a parent further up the tree.
*
* @param {MaybeComputedElementRef} target Element (or ref/getter) to watch for removal
* @param {OnElementRemovalCallback} callback Invoked with the mutation records that removed the element
* @param {OnElementRemovalOptions} [options={}] `window`, `document`, and watcher `flush` timing
* @returns {OnElementRemovalReturn} Stop handle that tears down the watcher and observer
*
* @example
* const el = useTemplateRef<HTMLElement>('el');
* onElementRemoval(el, () => console.log('gone'));
*
* @example
* const stop = onElementRemoval(el, (records) => report(records), { flush: 'post' });
*
* @since 0.0.15
*/
export function onElementRemoval(
target: MaybeComputedElementRef,
callback: OnElementRemovalCallback,
options: OnElementRemovalOptions = {},
): OnElementRemovalReturn {
const {
window = defaultWindow,
document = window?.document,
flush = 'sync',
} = options;
// SSR
if (!window || !document)
return noop;
let stopObserver: VoidFunction | undefined;
const disconnect = () => {
stopObserver?.();
stopObserver = undefined;
};
const stopWatch = watch(
() => unrefElement(target),
(el) => {
disconnect();
if (!el)
return;
// Observe the element's owning document so removal of any ancestor is caught,
// and so the correct root is used for elements inside iframes / shadow trees.
const root = el.ownerDocument ?? document;
const { stop } = useMutationObserver(
root.documentElement ?? (root as unknown as Element),
(mutationRecords) => {
const removed = mutationRecords.some(record =>
Array.prototype.some.call(record.removedNodes, (node: Node) => node === el || node.contains(el)),
);
if (removed)
callback(mutationRecords);
},
{
window,
childList: true,
subtree: true,
},
);
stopObserver = stop;
},
{ immediate: true, flush },
);
const stop: VoidFunction = () => {
stopWatch();
disconnect();
};
return stop;
}
@@ -0,0 +1,207 @@
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();
});
});
@@ -0,0 +1,111 @@
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/elements/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 Elements
* @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;
}
@@ -0,0 +1,143 @@
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();
});
});
@@ -0,0 +1,67 @@
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 Elements
* @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;
}
@@ -0,0 +1,131 @@
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();
});
});
@@ -0,0 +1,67 @@
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 Elements
* @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;
}
@@ -0,0 +1,351 @@
import { describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, shallowRef } from 'vue';
import { useDraggable } from '.';
interface PointerCoords {
clientX?: number;
clientY?: number;
button?: number;
pointerType?: string;
}
function dispatchPointer(targetEl: EventTarget, type: string, coords: PointerCoords = {}): Event {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clientX', { value: coords.clientX ?? 0, configurable: true });
Object.defineProperty(event, 'clientY', { value: coords.clientY ?? 0, configurable: true });
Object.defineProperty(event, 'button', { value: coords.button ?? 0, configurable: true });
Object.defineProperty(event, 'pointerType', { value: coords.pointerType ?? 'mouse', configurable: true });
Object.defineProperty(event, 'target', { value: targetEl, configurable: true });
targetEl.dispatchEvent(event);
return event;
}
function makeElement(rect: Partial<DOMRect> = {}): HTMLElement {
const el = document.createElement('div');
const full: DOMRect = {
x: 0,
y: 0,
top: 0,
left: 0,
right: 0,
bottom: 0,
width: 0,
height: 0,
toJSON: () => ({}),
...rect,
} as DOMRect;
el.getBoundingClientRect = () => full;
return el;
}
describe(useDraggable, () => {
it('uses the initial value', () => {
const el = makeElement();
const scope = effectScope();
let drag: ReturnType<typeof useDraggable>;
scope.run(() => {
drag = useDraggable(el, { initialValue: { x: 20, y: 30 } });
});
expect(drag!.x.value).toBe(20);
expect(drag!.y.value).toBe(30);
expect(drag!.position.value).toEqual({ x: 20, y: 30 });
expect(drag!.isDragging.value).toBeFalsy();
scope.stop();
});
it('defaults to { x: 0, y: 0 }', () => {
const el = makeElement();
const scope = effectScope();
let drag: ReturnType<typeof useDraggable>;
scope.run(() => {
drag = useDraggable(el);
});
expect(drag!.position.value).toEqual({ x: 0, y: 0 });
scope.stop();
});
it('drags along both axes and tracks isDragging', async () => {
const el = makeElement({ left: 0, top: 0, width: 50, height: 50 });
const scope = effectScope();
let drag: ReturnType<typeof useDraggable>;
scope.run(() => {
drag = useDraggable(el);
});
await nextTick();
dispatchPointer(el, 'pointerdown', { clientX: 10, clientY: 10 });
expect(drag!.isDragging.value).toBeTruthy();
dispatchPointer(globalThis, 'pointermove', { clientX: 40, clientY: 60 });
// delta captured at start was (10, 10); new pos = client - delta
expect(drag!.position.value).toEqual({ x: 30, y: 50 });
dispatchPointer(globalThis, 'pointerup', { clientX: 40, clientY: 60 });
expect(drag!.isDragging.value).toBeFalsy();
// moving after end does nothing
dispatchPointer(globalThis, 'pointermove', { clientX: 100, clientY: 100 });
expect(drag!.position.value).toEqual({ x: 30, y: 50 });
scope.stop();
});
it('locks to the x axis', async () => {
const el = makeElement();
const scope = effectScope();
let drag: ReturnType<typeof useDraggable>;
scope.run(() => {
drag = useDraggable(el, { axis: 'x' });
});
await nextTick();
dispatchPointer(el, 'pointerdown', { clientX: 0, clientY: 0 });
dispatchPointer(globalThis, 'pointermove', { clientX: 25, clientY: 99 });
expect(drag!.position.value).toEqual({ x: 25, y: 0 });
scope.stop();
});
it('locks to the y axis', async () => {
const el = makeElement();
const scope = effectScope();
let drag: ReturnType<typeof useDraggable>;
scope.run(() => {
drag = useDraggable(el, { axis: 'y' });
});
await nextTick();
dispatchPointer(el, 'pointerdown', { clientX: 0, clientY: 0 });
dispatchPointer(globalThis, 'pointermove', { clientX: 99, clientY: 25 });
expect(drag!.position.value).toEqual({ x: 0, y: 25 });
scope.stop();
});
it('fires onStart, onMove and onEnd callbacks', async () => {
const el = makeElement();
const onStart = vi.fn();
const onMove = vi.fn();
const onEnd = vi.fn();
const scope = effectScope();
scope.run(() => {
useDraggable(el, { onStart, onMove, onEnd });
});
await nextTick();
dispatchPointer(el, 'pointerdown', { clientX: 5, clientY: 5 });
dispatchPointer(globalThis, 'pointermove', { clientX: 15, clientY: 25 });
dispatchPointer(globalThis, 'pointerup', { clientX: 15, clientY: 25 });
expect(onStart).toHaveBeenCalledTimes(1);
expect(onMove).toHaveBeenCalledWith({ x: 10, y: 20 }, expect.any(Object));
expect(onEnd).toHaveBeenCalledWith({ x: 10, y: 20 }, expect.any(Object));
scope.stop();
});
it('cancels the drag when onStart returns false', async () => {
const el = makeElement();
const scope = effectScope();
let drag: ReturnType<typeof useDraggable>;
scope.run(() => {
drag = useDraggable(el, { onStart: () => false });
});
await nextTick();
dispatchPointer(el, 'pointerdown', { clientX: 0, clientY: 0 });
expect(drag!.isDragging.value).toBeFalsy();
dispatchPointer(globalThis, 'pointermove', { clientX: 50, clientY: 50 });
expect(drag!.position.value).toEqual({ x: 0, y: 0 });
scope.stop();
});
it('does not drag when disabled', async () => {
const el = makeElement();
const scope = effectScope();
let drag: ReturnType<typeof useDraggable>;
scope.run(() => {
drag = useDraggable(el, { disabled: true });
});
await nextTick();
dispatchPointer(el, 'pointerdown', { clientX: 0, clientY: 0 });
dispatchPointer(globalThis, 'pointermove', { clientX: 30, clientY: 30 });
expect(drag!.isDragging.value).toBeFalsy();
expect(drag!.position.value).toEqual({ x: 0, y: 0 });
scope.stop();
});
it('respects a reactive disabled flag', async () => {
const el = makeElement();
const disabled = shallowRef(true);
const scope = effectScope();
let drag: ReturnType<typeof useDraggable>;
scope.run(() => {
drag = useDraggable(el, { disabled });
});
await nextTick();
dispatchPointer(el, 'pointerdown', { clientX: 0, clientY: 0 });
expect(drag!.isDragging.value).toBeFalsy();
disabled.value = false;
dispatchPointer(el, 'pointerdown', { clientX: 0, clientY: 0 });
dispatchPointer(globalThis, 'pointermove', { clientX: 10, clientY: 10 });
expect(drag!.position.value).toEqual({ x: 10, y: 10 });
scope.stop();
});
it('ignores non-allowed pointer buttons', async () => {
const el = makeElement();
const scope = effectScope();
let drag: ReturnType<typeof useDraggable>;
scope.run(() => {
drag = useDraggable(el);
});
await nextTick();
// right button (2) should be ignored when default buttons = [0]
dispatchPointer(el, 'pointerdown', { clientX: 0, clientY: 0, button: 2 });
expect(drag!.isDragging.value).toBeFalsy();
scope.stop();
});
it('filters by pointer type', async () => {
const el = makeElement();
const scope = effectScope();
let drag: ReturnType<typeof useDraggable>;
scope.run(() => {
drag = useDraggable(el, { pointerTypes: ['touch'] });
});
await nextTick();
dispatchPointer(el, 'pointerdown', { clientX: 0, clientY: 0, pointerType: 'mouse' });
expect(drag!.isDragging.value).toBeFalsy();
dispatchPointer(el, 'pointerdown', { clientX: 0, clientY: 0, pointerType: 'touch' });
expect(drag!.isDragging.value).toBeTruthy();
scope.stop();
});
it('starts from a separate handle element', async () => {
const el = makeElement();
const handle = makeElement();
const scope = effectScope();
let drag: ReturnType<typeof useDraggable>;
scope.run(() => {
drag = useDraggable(el, { handle });
});
await nextTick();
// pointerdown on the target itself should NOT start a drag
dispatchPointer(el, 'pointerdown', { clientX: 0, clientY: 0 });
expect(drag!.isDragging.value).toBeFalsy();
// pointerdown on the handle should
dispatchPointer(handle, 'pointerdown', { clientX: 0, clientY: 0 });
expect(drag!.isDragging.value).toBeTruthy();
scope.stop();
});
it('honours exact (only starts on the target itself, not children)', async () => {
const el = makeElement();
const child = makeElement();
el.appendChild(child);
const scope = effectScope();
let drag: ReturnType<typeof useDraggable>;
scope.run(() => {
drag = useDraggable(el, { exact: true });
});
await nextTick();
// event.target is the child -> should be ignored
dispatchPointer(child, 'pointerdown', { clientX: 0, clientY: 0 });
expect(drag!.isDragging.value).toBeFalsy();
// event.target is the element itself -> allowed
dispatchPointer(el, 'pointerdown', { clientX: 0, clientY: 0 });
expect(drag!.isDragging.value).toBeTruthy();
scope.stop();
});
it('clamps within a container element', async () => {
const el = makeElement({ left: 0, top: 0, width: 20, height: 20 });
const container = makeElement({ left: 0, top: 0, width: 100, height: 100 });
Object.defineProperty(container, 'scrollWidth', { value: 100, configurable: true });
Object.defineProperty(container, 'scrollHeight', { value: 100, configurable: true });
Object.defineProperty(container, 'scrollLeft', { value: 0, configurable: true });
Object.defineProperty(container, 'scrollTop', { value: 0, configurable: true });
const scope = effectScope();
let drag: ReturnType<typeof useDraggable>;
scope.run(() => {
drag = useDraggable(el, { containerElement: container });
});
await nextTick();
dispatchPointer(el, 'pointerdown', { clientX: 0, clientY: 0 });
// try to drag way past the container; clamps to scrollWidth - width = 80
dispatchPointer(globalThis, 'pointermove', { clientX: 500, clientY: 500 });
expect(drag!.position.value).toEqual({ x: 80, y: 80 });
// negative also clamps to 0
dispatchPointer(globalThis, 'pointermove', { clientX: -50, clientY: -50 });
expect(drag!.position.value).toEqual({ x: 0, y: 0 });
scope.stop();
});
it('exposes a writable x/y that updates position', () => {
const el = makeElement();
const scope = effectScope();
let drag: ReturnType<typeof useDraggable>;
scope.run(() => {
drag = useDraggable(el);
});
drag!.x.value = 11;
drag!.y.value = 22;
expect(drag!.position.value).toEqual({ x: 11, y: 22 });
scope.stop();
});
it('produces a style string', () => {
const el = makeElement();
const scope = effectScope();
let drag: ReturnType<typeof useDraggable>;
scope.run(() => {
drag = useDraggable(el, { initialValue: { x: 4, y: 8 } });
});
expect(drag!.style.value).toBe('left:4px;top:8px;');
scope.stop();
});
it('attaches passive listeners when not preventing default', async () => {
const el = makeElement();
const addSpy = vi.spyOn(el, 'addEventListener');
const scope = effectScope();
scope.run(() => {
useDraggable(el);
});
await nextTick();
const downCall = addSpy.mock.calls.find(([name]) => name === 'pointerdown');
expect(downCall).toBeDefined();
expect((downCall![2] as AddEventListenerOptions).passive).toBeTruthy();
addSpy.mockRestore();
scope.stop();
});
it('does nothing on the SSR path (no window)', () => {
// Simulate a missing window by passing draggingElement undefined and a
// detached element; the composable must still return sane defaults.
const el = makeElement();
const scope = effectScope();
let drag: ReturnType<typeof useDraggable>;
scope.run(() => {
drag = useDraggable(el, { draggingElement: undefined });
});
expect(drag!.x.value).toBe(0);
expect(drag!.y.value).toBe(0);
expect(drag!.isDragging.value).toBeFalsy();
expect(drag!.style.value).toBe('left:0px;top:0px;');
scope.stop();
});
});
@@ -0,0 +1,323 @@
import { computed, shallowRef, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue';
import { noop } from '@robonen/stdlib';
import { defaultWindow } from '@/types';
import { useEventListener } from '@/composables/browser/useEventListener';
import { unrefElement } from '@/composables/component/unrefElement';
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
export type UseDraggableAxis = 'x' | 'y' | 'both';
export type UseDraggablePointerType = 'mouse' | 'touch' | 'pen';
export interface Position {
x: number;
y: number;
}
export interface UseDraggableOptions {
/**
* Initial position of the draggable element.
*
* @default { x: 0, y: 0 }
*/
initialValue?: MaybeRefOrGetter<Position>;
/**
* Axis along which dragging is allowed.
*
* @default 'both'
*/
axis?: UseDraggableAxis;
/**
* Element that initiates the drag. Defaults to the dragged `target` itself.
* Accepts an element ref, getter, or component instance.
*
* @default target
*/
handle?: MaybeComputedElementRef;
/**
* Element whose bounds constrain the dragged element. When set, the position
* is clamped so the element cannot be dragged outside of it.
*
* @default undefined
*/
containerElement?: MaybeComputedElementRef;
/**
* Element on which the `pointermove` / `pointerup` listeners are attached.
* Defaults to `window` so dragging keeps working when the pointer leaves the
* element.
*
* @default window
*/
draggingElement?: MaybeComputedElementRef | Window | Document;
/**
* Only start dragging when the pointer goes down on the `handle` itself, not
* on one of its descendants.
*
* @default false
*/
exact?: MaybeRefOrGetter<boolean>;
/**
* Pointer types that are allowed to start a drag.
*
* @default ['mouse', 'touch', 'pen']
*/
pointerTypes?: UseDraggablePointerType[];
/**
* Pointer buttons that are allowed to start a drag (`0` = primary/left).
*
* @default [0]
*/
buttons?: MaybeRefOrGetter<number[]>;
/**
* Call `preventDefault` on the pointer events. When `false` listeners are
* attached passively for better scroll performance.
*
* @default false
*/
preventDefault?: MaybeRefOrGetter<boolean>;
/**
* Call `stopPropagation` on the pointer events.
*
* @default false
*/
stopPropagation?: MaybeRefOrGetter<boolean>;
/**
* Use event capture when attaching the listeners.
*
* @default true
*/
capture?: boolean;
/**
* Disable dragging entirely. May be reactive to toggle at runtime.
*
* @default false
*/
disabled?: MaybeRefOrGetter<boolean>;
/**
* Invoked when a drag starts. Return `false` to cancel the drag.
*/
onStart?: (position: Position, event: PointerEvent) => void | false;
/**
* Invoked on every pointer move while dragging.
*/
onMove?: (position: Position, event: PointerEvent) => void;
/**
* Invoked when the drag ends.
*/
onEnd?: (position: Position, event: PointerEvent) => void;
}
export interface UseDraggableReturn {
/**
* Current x position.
*/
x: Ref<number>;
/**
* Current y position.
*/
y: Ref<number>;
/**
* Current position as a `{ x, y }` object.
*/
position: Ref<Position>;
/**
* Whether a drag is currently in progress.
*/
isDragging: ComputedRef<boolean>;
/**
* Ready-to-bind inline `style` string positioning the element.
*/
style: ComputedRef<string>;
}
/**
* @name useDraggable
* @category Elements
* @description Make an element draggable by pointer, tracking its position with
* optional axis locking, a drag handle, container constraints, and lifecycle
* callbacks. SSR-safe and built on passive pointer listeners.
*
* @param {MaybeComputedElementRef} target - The element to make draggable
* @param {UseDraggableOptions} [options={}] - Options
* @returns {UseDraggableReturn} Reactive `x`, `y`, `position`, `isDragging`, and a `style` string
*
* @example
* const el = useTemplateRef<HTMLElement>('el');
* const { x, y, style } = useDraggable(el, { initialValue: { x: 40, y: 40 } });
*
* @example
* // Lock to the horizontal axis and only drag from a handle.
* const { position } = useDraggable(el, { axis: 'x', handle: handleEl });
*
* @since 0.0.15
*/
export function useDraggable(
target: MaybeComputedElementRef,
options: UseDraggableOptions = {},
): UseDraggableReturn {
const {
initialValue,
axis = 'both',
handle = target,
containerElement,
draggingElement = defaultWindow,
exact,
pointerTypes,
buttons = [0],
preventDefault,
stopPropagation,
capture = true,
disabled,
onStart = noop,
onMove = noop,
onEnd = noop,
} = options;
const position = shallowRef<Position>(toValue(initialValue) ?? { x: 0, y: 0 });
// Offset from the pointer to the element's top-left at drag start.
// `null` means we are not dragging.
const pressedDelta = shallowRef<Position | null>(null);
const filterEvent = (event: PointerEvent): boolean => {
if (pointerTypes)
return pointerTypes.includes(event.pointerType as UseDraggablePointerType);
return true;
};
const handleEvent = (event: PointerEvent): void => {
if (toValue(preventDefault))
event.preventDefault();
if (toValue(stopPropagation))
event.stopPropagation();
};
const start = (event: PointerEvent): void => {
if (toValue(disabled))
return;
if (!toValue(buttons).includes(event.button))
return;
if (!filterEvent(event))
return;
const el = unrefElement(target) as HTMLElement | SVGElement | null | undefined;
if (toValue(exact) && event.target !== el)
return;
const container = unrefElement(containerElement) as HTMLElement | SVGElement | null | undefined;
const containerRect = container?.getBoundingClientRect();
const targetRect = el?.getBoundingClientRect();
if (!targetRect)
return;
const pos: Position = {
x: event.clientX - (container ? targetRect.left - containerRect!.left + container.scrollLeft : targetRect.left),
y: event.clientY - (container ? targetRect.top - containerRect!.top + container.scrollTop : targetRect.top),
};
if (onStart(pos, event) === false)
return;
pressedDelta.value = pos;
handleEvent(event);
};
const move = (event: PointerEvent): void => {
if (toValue(disabled))
return;
if (!pressedDelta.value)
return;
if (!filterEvent(event))
return;
const el = unrefElement(target) as HTMLElement | SVGElement | null | undefined;
const container = unrefElement(containerElement) as HTMLElement | SVGElement | null | undefined;
const targetRect = el?.getBoundingClientRect();
let { x, y } = position.value;
if (axis === 'x' || axis === 'both') {
x = event.clientX - pressedDelta.value.x;
if (container && targetRect)
x = Math.min(Math.max(0, x), container.scrollWidth - targetRect.width);
}
if (axis === 'y' || axis === 'both') {
y = event.clientY - pressedDelta.value.y;
if (container && targetRect)
y = Math.min(Math.max(0, y), container.scrollHeight - targetRect.height);
}
position.value = { x, y };
onMove(position.value, event);
handleEvent(event);
};
const end = (event: PointerEvent): void => {
if (toValue(disabled))
return;
if (!pressedDelta.value)
return;
if (!filterEvent(event))
return;
pressedDelta.value = null;
onEnd(position.value, event);
handleEvent(event);
};
if (defaultWindow) {
const config = (): AddEventListenerOptions => ({
capture,
passive: !toValue(preventDefault),
});
// `MaybeComputedElementRef` includes `VueInstance`, which isn't an `EventTarget`;
// these targets always resolve to a real element/window at runtime (useEventListener
// unwraps via unrefElement), so cast to the EventTarget overload.
const asEventTarget = (value: unknown): MaybeRefOrGetter<EventTarget | null | undefined> =>
value as MaybeRefOrGetter<EventTarget | null | undefined>;
useEventListener(asEventTarget(handle), 'pointerdown', start as (e: Event) => void, config);
useEventListener(asEventTarget(draggingElement), 'pointermove', move as (e: Event) => void, config);
useEventListener(asEventTarget(draggingElement), 'pointerup', end as (e: Event) => void, config);
}
const x = computed<number>({
get: () => position.value.x,
set: value => (position.value = { x: value, y: position.value.y }),
});
const y = computed<number>({
get: () => position.value.y,
set: value => (position.value = { x: position.value.x, y: value }),
});
return {
x,
y,
position,
isDragging: computed(() => !!pressedDelta.value),
style: computed(() => `left:${position.value.x}px;top:${position.value.y}px;`),
};
}
@@ -0,0 +1,288 @@
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();
});
});
@@ -0,0 +1,205 @@
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/utilities/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 Elements
* @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,
};
}
@@ -0,0 +1,153 @@
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);
});
});
@@ -0,0 +1,199 @@
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/elements/useResizeObserver';
import { useMutationObserver } from '@/composables/elements/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 Elements
* @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,
};
}
@@ -0,0 +1,229 @@
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();
});
});
@@ -0,0 +1,121 @@
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/elements/useResizeObserver';
import type { UseResizeObserverOptions } from '@/composables/elements/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 Elements
* @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 };
}
@@ -0,0 +1,145 @@
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();
});
});
@@ -0,0 +1,108 @@
import { shallowRef } from 'vue';
import type { ShallowRef } from 'vue';
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
import { useIntersectionObserver } from '@/composables/elements/useIntersectionObserver';
import type { UseIntersectionObserverOptions, UseIntersectionObserverReturn } from '@/composables/elements/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 Elements
* @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;
}
@@ -0,0 +1,69 @@
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);
});
});
@@ -0,0 +1,40 @@
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 Elements
* @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);
}
@@ -0,0 +1,206 @@
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();
});
});
@@ -0,0 +1,150 @@
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/utilities/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 Elements
* @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,
};
}
@@ -0,0 +1,193 @@
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();
});
});
@@ -0,0 +1,140 @@
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/utilities/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 Elements
* @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,
};
}
@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest';
import { defineComponent, nextTick, shallowRef } from 'vue';
import { mount } from '@vue/test-utils';
import type { UseParentElementReturn } from '.';
import { useParentElement } from '.';
describe(useParentElement, () => {
it('resolves to the parent of an explicit element ref', async () => {
const child = document.createElement('span');
const parent = document.createElement('div');
parent.appendChild(child);
const elRef = shallowRef<HTMLElement | null>(child);
const result = useParentElement(elRef);
await nextTick();
expect(result.value).toBe(parent);
});
it('reacts when the target element ref changes', async () => {
const parentA = document.createElement('div');
const childA = document.createElement('span');
parentA.appendChild(childA);
const parentB = document.createElement('section');
const childB = document.createElement('p');
parentB.appendChild(childB);
const elRef = shallowRef<HTMLElement | null>(childA);
const result = useParentElement(elRef);
await nextTick();
expect(result.value).toBe(parentA);
elRef.value = childB;
await nextTick();
expect(result.value).toBe(parentB);
});
it('accepts a getter as the target', async () => {
const child = document.createElement('span');
const parent = document.createElement('article');
parent.appendChild(child);
const result = useParentElement(() => child);
await nextTick();
expect(result.value).toBe(parent);
});
it('resolves to null/undefined when the element has no parent', async () => {
const orphan = document.createElement('span');
const result = useParentElement(shallowRef<HTMLElement | null>(orphan));
await nextTick();
expect(result.value).toBeFalsy();
});
it('resolves to undefined when the target ref is null (SSR / unmounted path)', async () => {
const elRef = shallowRef<HTMLElement | null>(null);
const result = useParentElement(elRef);
await nextTick();
expect(result.value).toBeUndefined();
});
it('updates to undefined when the target becomes null', async () => {
const child = document.createElement('span');
const parent = document.createElement('div');
parent.appendChild(child);
const elRef = shallowRef<HTMLElement | null>(child);
const result = useParentElement(elRef);
await nextTick();
expect(result.value).toBe(parent);
elRef.value = null;
await nextTick();
expect(result.value).toBeUndefined();
});
it('defaults to the current instance root element parent', async () => {
let result!: UseParentElementReturn;
const Child = defineComponent({
setup() {
result = useParentElement();
return {};
},
template: `<span class="leaf">leaf</span>`,
});
const Parent = defineComponent({
components: { Child },
template: `<div class="wrapper"><Child /></div>`,
});
const wrapper = mount(Parent);
await nextTick();
expect(result.value).toBe(wrapper.find('.wrapper').element);
});
it('does not throw outside a component instance (SSR-safe default)', () => {
let result!: UseParentElementReturn;
expect(() => {
result = useParentElement();
}).not.toThrow();
expect(result).toBeDefined();
expect(result.value).toBeUndefined();
});
});
@@ -0,0 +1,49 @@
import { shallowRef, watch } from 'vue';
import type { MaybeRefOrGetter, ShallowRef } from 'vue';
import { unrefElement } from '@/composables/component/unrefElement';
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
import { useCurrentElement } from '@/composables/component/useCurrentElement';
export type UseParentElementReturn
= Readonly<ShallowRef<HTMLElement | SVGElement | null | undefined>>;
/**
* @name useParentElement
* @category Elements
* @description Reactive `parentElement` of a given element (or the current
* component instance's root element when no target is supplied). Resolves the
* target through `unrefElement`, so it accepts plain elements, template refs,
* component instances, getters and computed refs. A single `immediate` watcher
* tracks the resolved target and re-reads its parent only when the element
* itself changes — no extra lifecycle hooks or always-on observers. SSR-safe:
* stays `undefined` until the target is resolved on the client.
*
* @param {MaybeComputedElementRef | MaybeRefOrGetter<HTMLElement | SVGElement | null | undefined>} [element] Target element/ref/getter; defaults to the current instance's root element
* @returns {UseParentElementReturn} A read-only shallow ref of the resolved parent element
*
* @example
* // Parent of the current component's root element
* const parent = useParentElement();
*
* @example
* // Parent of a specific template ref
* const el = useTemplateRef<HTMLElement>('el');
* const parent = useParentElement(el);
*
* @since 0.0.15
*/
export function useParentElement(
element: MaybeComputedElementRef | MaybeRefOrGetter<HTMLElement | SVGElement | null | undefined> = useCurrentElement<HTMLElement | SVGAElement>(),
): UseParentElementReturn {
const parentElement = shallowRef<HTMLElement | SVGElement | null | undefined>();
watch(
() => unrefElement(element as MaybeComputedElementRef),
(el) => {
parentElement.value = (el as Element | null | undefined)?.parentElement;
},
{ immediate: true, flush: 'post' },
);
return parentElement;
}
@@ -0,0 +1,188 @@
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();
});
});
@@ -0,0 +1,149 @@
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/utilities/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 Elements
* @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,
};
}
@@ -0,0 +1,169 @@
import { describe, expect, it } from 'vitest';
import { effectScope, isReadonly } from 'vue';
import { useWindowFocus } from '.';
interface FakeWindow {
document: { hasFocus: () => boolean };
addEventListener: Window['addEventListener'];
removeEventListener: Window['removeEventListener'];
dispatchEvent: Window['dispatchEvent'];
}
// Build a minimal window-like object whose event plumbing is driven by a real
// EventTarget, while `document.hasFocus()` is controllable for initial state.
function createFakeWindow(initialFocus: boolean): FakeWindow {
const target = new EventTarget();
return {
document: { hasFocus: () => initialFocus },
addEventListener: target.addEventListener.bind(target) as Window['addEventListener'],
removeEventListener: target.removeEventListener.bind(target) as Window['removeEventListener'],
dispatchEvent: target.dispatchEvent.bind(target) as Window['dispatchEvent'],
};
}
describe(useWindowFocus, () => {
it('initialises from document.hasFocus() (focused)', () => {
const fakeWindow = createFakeWindow(true);
const scope = effectScope();
let focused: ReturnType<typeof useWindowFocus>;
scope.run(() => {
focused = useWindowFocus({ window: fakeWindow as unknown as Window });
});
expect(focused!.value).toBeTruthy();
scope.stop();
});
it('initialises from document.hasFocus() (blurred)', () => {
const fakeWindow = createFakeWindow(false);
const scope = effectScope();
let focused: ReturnType<typeof useWindowFocus>;
scope.run(() => {
focused = useWindowFocus({ window: fakeWindow as unknown as Window });
});
expect(focused!.value).toBeFalsy();
scope.stop();
});
it('becomes false on blur', () => {
const fakeWindow = createFakeWindow(true);
const scope = effectScope();
let focused: ReturnType<typeof useWindowFocus>;
scope.run(() => {
focused = useWindowFocus({ window: fakeWindow as unknown as Window });
});
expect(focused!.value).toBeTruthy();
fakeWindow.dispatchEvent(new Event('blur'));
expect(focused!.value).toBeFalsy();
scope.stop();
});
it('becomes true on focus', () => {
const fakeWindow = createFakeWindow(false);
const scope = effectScope();
let focused: ReturnType<typeof useWindowFocus>;
scope.run(() => {
focused = useWindowFocus({ window: fakeWindow as unknown as Window });
});
expect(focused!.value).toBeFalsy();
fakeWindow.dispatchEvent(new Event('focus'));
expect(focused!.value).toBeTruthy();
scope.stop();
});
it('tracks repeated focus/blur transitions', () => {
const fakeWindow = createFakeWindow(true);
const scope = effectScope();
let focused: ReturnType<typeof useWindowFocus>;
scope.run(() => {
focused = useWindowFocus({ window: fakeWindow as unknown as Window });
});
fakeWindow.dispatchEvent(new Event('blur'));
expect(focused!.value).toBeFalsy();
fakeWindow.dispatchEvent(new Event('focus'));
expect(focused!.value).toBeTruthy();
fakeWindow.dispatchEvent(new Event('blur'));
expect(focused!.value).toBeFalsy();
scope.stop();
});
it('removes listeners when the scope is disposed', () => {
const fakeWindow = createFakeWindow(true);
const scope = effectScope();
let focused: ReturnType<typeof useWindowFocus>;
scope.run(() => {
focused = useWindowFocus({ window: fakeWindow as unknown as Window });
});
scope.stop();
// after disposal, events must no longer mutate the ref
fakeWindow.dispatchEvent(new Event('blur'));
expect(focused!.value).toBeTruthy();
});
it('returns a writable shallow ref (not readonly)', () => {
const fakeWindow = createFakeWindow(true);
const scope = effectScope();
let focused: ReturnType<typeof useWindowFocus>;
scope.run(() => {
focused = useWindowFocus({ window: fakeWindow as unknown as Window });
});
expect(isReadonly(focused!)).toBeFalsy();
scope.stop();
});
it('returns false and does not throw when window is unavailable (SSR)', () => {
const scope = effectScope();
let focused: ReturnType<typeof useWindowFocus>;
scope.run(() => {
focused = useWindowFocus({ window: undefined });
});
expect(focused!.value).toBeFalsy();
scope.stop();
});
it('uses the real jsdom window by default', () => {
const scope = effectScope();
let focused: ReturnType<typeof useWindowFocus>;
scope.run(() => {
focused = useWindowFocus();
});
// initial value mirrors document.hasFocus()
expect(focused!.value).toBe(document.hasFocus());
globalThis.dispatchEvent(new Event('blur'));
expect(focused!.value).toBeFalsy();
globalThis.dispatchEvent(new Event('focus'));
expect(focused!.value).toBeTruthy();
scope.stop();
});
});
@@ -0,0 +1,43 @@
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 UseWindowFocusOptions extends ConfigurableWindow {}
export type UseWindowFocusReturn = ShallowRef<boolean>;
/**
* @name useWindowFocus
* @category Elements
* @description Reactively track whether the window is focused via `focus`/`blur` events.
*
* @param {UseWindowFocusOptions} [options={}] Options
* @returns {UseWindowFocusReturn} A shallow ref that is `true` while the window has focus
*
* @example
* const focused = useWindowFocus();
*
* @since 0.0.15
*/
export function useWindowFocus(options: UseWindowFocusOptions = {}): UseWindowFocusReturn {
const { window = defaultWindow } = options;
if (!window)
return shallowRef(false);
const focused = shallowRef(window.document.hasFocus());
const listenerOptions = { passive: true } as const;
useEventListener(window, 'blur', () => {
focused.value = false;
}, listenerOptions);
useEventListener(window, 'focus', () => {
focused.value = true;
}, listenerOptions);
return focused;
}
@@ -0,0 +1,244 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick } from 'vue';
import { useWindowScroll } from '.';
function setScroll(x: number, y: number): void {
(globalThis as any).scrollX = x;
(globalThis as any).scrollY = y;
}
describe(useWindowScroll, () => {
beforeEach(() => {
Object.defineProperty(globalThis, 'scrollX', { value: 0, configurable: true, writable: true });
Object.defineProperty(globalThis, 'scrollY', { value: 0, configurable: true, writable: true });
});
afterEach(() => vi.unstubAllGlobals());
it('reads the initial scroll position', () => {
setScroll(15, 25);
const scope = effectScope();
let result: ReturnType<typeof useWindowScroll>;
scope.run(() => {
result = useWindowScroll();
});
expect(result!.x.value).toBe(15);
expect(result!.y.value).toBe(25);
scope.stop();
});
it('updates on scroll', async () => {
const scope = effectScope();
let result: ReturnType<typeof useWindowScroll>;
scope.run(() => {
result = useWindowScroll();
});
setScroll(30, 60);
globalThis.dispatchEvent(new Event('scroll'));
await nextTick();
expect(result!.x.value).toBe(30);
expect(result!.y.value).toBe(60);
scope.stop();
});
it('scrolls the window when writing to x/y', () => {
const scrollTo = vi.fn();
vi.stubGlobal('scrollTo', scrollTo);
const scope = effectScope();
let result: ReturnType<typeof useWindowScroll>;
scope.run(() => {
result = useWindowScroll();
});
result!.x.value = 100;
expect(scrollTo).toHaveBeenCalledWith(expect.objectContaining({ left: 100 }));
result!.y.value = 200;
expect(scrollTo).toHaveBeenCalledWith(expect.objectContaining({ top: 200 }));
scope.stop();
});
it('passes the configured behavior when writing to x/y', () => {
const scrollTo = vi.fn();
vi.stubGlobal('scrollTo', scrollTo);
const scope = effectScope();
let result: ReturnType<typeof useWindowScroll>;
scope.run(() => {
result = useWindowScroll({ behavior: 'smooth' });
});
result!.x.value = 50;
expect(scrollTo).toHaveBeenCalledWith(expect.objectContaining({ left: 50, behavior: 'smooth' }));
scope.stop();
});
it('exposes isScrolling that toggles on scroll and resets after idle', async () => {
vi.useFakeTimers();
const scope = effectScope();
let result: ReturnType<typeof useWindowScroll>;
scope.run(() => {
result = useWindowScroll({ idle: 50 });
});
expect(result!.isScrolling.value).toBeFalsy();
setScroll(10, 10);
globalThis.dispatchEvent(new Event('scroll'));
expect(result!.isScrolling.value).toBeTruthy();
vi.advanceTimersByTime(60);
await nextTick();
expect(result!.isScrolling.value).toBeFalsy();
scope.stop();
vi.useRealTimers();
});
it('calls onScroll and onStop callbacks', async () => {
vi.useFakeTimers();
const onScroll = vi.fn();
const onStop = vi.fn();
const scope = effectScope();
scope.run(() => {
useWindowScroll({ idle: 50, onScroll, onStop });
});
setScroll(5, 5);
globalThis.dispatchEvent(new Event('scroll'));
expect(onScroll).toHaveBeenCalledTimes(1);
expect(onStop).not.toHaveBeenCalled();
vi.advanceTimersByTime(60);
await nextTick();
expect(onStop).toHaveBeenCalledTimes(1);
scope.stop();
vi.useRealTimers();
});
it('tracks scroll directions', () => {
const scope = effectScope();
let result: ReturnType<typeof useWindowScroll>;
scope.run(() => {
result = useWindowScroll();
});
setScroll(40, 80);
globalThis.dispatchEvent(new Event('scroll'));
expect(result!.directions.right).toBeTruthy();
expect(result!.directions.bottom).toBeTruthy();
expect(result!.directions.left).toBeFalsy();
expect(result!.directions.top).toBeFalsy();
setScroll(10, 20);
globalThis.dispatchEvent(new Event('scroll'));
expect(result!.directions.left).toBeTruthy();
expect(result!.directions.top).toBeTruthy();
expect(result!.directions.right).toBeFalsy();
expect(result!.directions.bottom).toBeFalsy();
scope.stop();
});
it('reports arrivedState at the top/left edges initially', () => {
const scope = effectScope();
let result: ReturnType<typeof useWindowScroll>;
scope.run(() => {
result = useWindowScroll();
});
expect(result!.arrivedState.top).toBeTruthy();
expect(result!.arrivedState.left).toBeTruthy();
scope.stop();
});
it('clears arrivedState.top once scrolled down', () => {
const scope = effectScope();
let result: ReturnType<typeof useWindowScroll>;
scope.run(() => {
result = useWindowScroll();
});
setScroll(0, 100);
globalThis.dispatchEvent(new Event('scroll'));
expect(result!.arrivedState.top).toBeFalsy();
scope.stop();
});
it('honors the top offset for arrivedState', () => {
const scope = effectScope();
let result: ReturnType<typeof useWindowScroll>;
scope.run(() => {
result = useWindowScroll({ offset: { top: 30 } });
});
setScroll(0, 20);
globalThis.dispatchEvent(new Event('scroll'));
// Within the 30px offset, still considered "arrived at top".
expect(result!.arrivedState.top).toBeTruthy();
setScroll(0, 40);
globalThis.dispatchEvent(new Event('scroll'));
expect(result!.arrivedState.top).toBeFalsy();
scope.stop();
});
it('measure() recomputes state without a scroll event', () => {
const scope = effectScope();
let result: ReturnType<typeof useWindowScroll>;
scope.run(() => {
result = useWindowScroll();
});
setScroll(0, 70);
// No event dispatched; values are stale until measure().
expect(result!.y.value).toBe(0);
result!.measure();
expect(result!.y.value).toBe(70);
scope.stop();
});
it('is SSR-safe when window is undefined', () => {
const scope = effectScope();
let result: ReturnType<typeof useWindowScroll>;
scope.run(() => {
result = useWindowScroll({ window: undefined });
});
expect(result!.x.value).toBe(0);
expect(result!.y.value).toBe(0);
expect(result!.isScrolling.value).toBeFalsy();
// Writing should be a no-op (no throw).
expect(() => {
result!.x.value = 10;
}).not.toThrow();
scope.stop();
});
it('throttles the scroll handler when throttle is set', async () => {
vi.useFakeTimers();
const onScroll = vi.fn();
const scope = effectScope();
scope.run(() => {
useWindowScroll({ throttle: 100, onScroll });
});
setScroll(1, 1);
globalThis.dispatchEvent(new Event('scroll'));
setScroll(2, 2);
globalThis.dispatchEvent(new Event('scroll'));
setScroll(3, 3);
globalThis.dispatchEvent(new Event('scroll'));
// Trailing-only throttle: collapses the burst into a single deferred call.
vi.advanceTimersByTime(120);
await nextTick();
expect(onScroll).toHaveBeenCalledTimes(1);
scope.stop();
vi.useRealTimers();
});
});
@@ -0,0 +1,272 @@
import { computed, reactive, shallowRef, toValue } from 'vue';
import type { MaybeRefOrGetter, Reactive, ShallowRef, WritableComputedRef } from 'vue';
import { noop } from '@robonen/stdlib';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { useEventListener } from '@/composables/browser/useEventListener';
import { useDebounceFn } from '@/composables/reactivity/useDebounceFn';
import { useThrottleFn } from '@/composables/reactivity/useThrottleFn';
import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted';
/**
* `scrollTop`/`scrollLeft` are sub-pixel (fractional) numbers, while
* `scrollHeight`/`scrollWidth` and `clientHeight`/`clientWidth` are rounded
* integers. We therefore allow a 1px tolerance when deciding whether an edge
* has been reached.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#determine_if_an_element_has_been_totally_scrolled
*/
const ARRIVED_STATE_THRESHOLD_PIXELS = 1;
export interface UseWindowScrollOffset {
left?: number;
right?: number;
top?: number;
bottom?: number;
}
export interface UseWindowScrollEdgeState {
left: boolean;
right: boolean;
top: boolean;
bottom: boolean;
}
export interface UseWindowScrollOptions extends ConfigurableWindow {
/**
* Throttle time (ms) for the scroll handler. Disabled by default.
*
* @default 0
*/
throttle?: number;
/**
* Delay (ms) after the last scroll event before `isScrolling` flips back to
* `false`. When `throttle` is set the effective idle window becomes
* `throttle + idle`.
*
* @default 200
*/
idle?: number;
/**
* Offset the `arrivedState` edges by a number of pixels, e.g. to treat the
* page as "arrived at bottom" slightly before the true bottom.
*/
offset?: UseWindowScrollOffset;
/**
* Invoked on every (throttled) scroll event.
*/
onScroll?: (event: Event) => void;
/**
* Invoked once scrolling stops (after the idle window elapses).
*/
onStop?: (event: Event) => void;
/**
* Listener options for the scroll event.
*
* @default { capture: false, passive: true }
*/
eventListenerOptions?: boolean | AddEventListenerOptions;
/**
* Scroll behavior applied when writing to `x`/`y`. `'auto'` jumps instantly,
* `'smooth'` animates. Accepts a ref or getter for reactivity.
*
* @default 'auto'
*/
behavior?: MaybeRefOrGetter<ScrollBehavior>;
}
export interface UseWindowScrollReturn {
/**
* Reactive horizontal scroll position. Writing to it scrolls the window.
*/
x: WritableComputedRef<number>;
/**
* Reactive vertical scroll position. Writing to it scrolls the window.
*/
y: WritableComputedRef<number>;
/**
* Whether the window is currently being scrolled.
*/
isScrolling: ShallowRef<boolean>;
/**
* Whether each edge of the document has been reached.
*/
arrivedState: Reactive<UseWindowScrollEdgeState>;
/**
* The direction(s) the window is currently scrolling towards.
*/
directions: Reactive<UseWindowScrollEdgeState>;
/**
* Force a re-measurement of `arrivedState`/`directions`.
*/
measure: () => void;
}
/**
* @name useWindowScroll
* @category Elements
* @description Reactive window scroll position with arrived/direction tracking. Writing to `x`/`y` scrolls the window.
*
* @param {UseWindowScrollOptions} [options={}] Options
* @returns {UseWindowScrollReturn} Reactive `x`, `y`, `isScrolling`, `arrivedState`, `directions` and a `measure()` helper
*
* @example
* const { x, y, isScrolling, arrivedState, directions } = useWindowScroll();
*
* @since 0.0.15
*/
export function useWindowScroll(options: UseWindowScrollOptions = {}): UseWindowScrollReturn {
const {
window = defaultWindow,
throttle = 0,
idle = 200,
onStop = noop,
onScroll = noop,
offset = {},
eventListenerOptions = { capture: false, passive: true },
behavior = 'auto',
} = options;
const internalX = shallowRef(0);
const internalY = shallowRef(0);
// We use computed getters/setters so that writing `x`/`y` triggers a real
// `scrollTo()` while the internal refs are updated from the scroll event
// without re-triggering a scroll.
const x = computed<number>({
get: () => internalX.value,
set: value => scrollTo(value, undefined),
});
const y = computed<number>({
get: () => internalY.value,
set: value => scrollTo(undefined, value),
});
function scrollTo(_x: number | undefined, _y: number | undefined): void {
if (!window)
return;
window.scrollTo({
left: _x ?? internalX.value,
top: _y ?? internalY.value,
behavior: toValue(behavior),
});
if (_x !== null && _x !== undefined)
internalX.value = _x;
if (_y !== null && _y !== undefined)
internalY.value = _y;
}
const isScrolling = shallowRef(false);
const arrivedState = reactive<UseWindowScrollEdgeState>({
left: true,
right: false,
top: true,
bottom: false,
});
const directions = reactive<UseWindowScrollEdgeState>({
left: false,
right: false,
top: false,
bottom: false,
});
function setArrivedState(): void {
if (!window)
return;
const el = window.document.documentElement;
const { direction } = window.getComputedStyle(el);
const directionMultiplier = direction === 'rtl' ? -1 : 1;
const scrollLeft = window.scrollX;
directions.left = scrollLeft < internalX.value;
directions.right = scrollLeft > internalX.value;
arrivedState.left = Math.abs(scrollLeft * directionMultiplier) <= (offset.left ?? 0);
arrivedState.right = Math.abs(scrollLeft * directionMultiplier)
+ el.clientWidth >= el.scrollWidth
- (offset.right ?? 0)
- ARRIVED_STATE_THRESHOLD_PIXELS;
internalX.value = scrollLeft;
const scrollTop = window.scrollY;
directions.top = scrollTop < internalY.value;
directions.bottom = scrollTop > internalY.value;
arrivedState.top = Math.abs(scrollTop) <= (offset.top ?? 0);
arrivedState.bottom = Math.abs(scrollTop)
+ el.clientHeight >= el.scrollHeight
- (offset.bottom ?? 0)
- ARRIVED_STATE_THRESHOLD_PIXELS;
internalY.value = scrollTop;
}
function onScrollEnd(event: Event): void {
// Dedupe in case the native `scrollend` event is supported.
if (!isScrolling.value)
return;
isScrolling.value = false;
directions.left = false;
directions.right = false;
directions.top = false;
directions.bottom = false;
onStop(event);
}
const onScrollEndDebounced = useDebounceFn(onScrollEnd, throttle + idle);
function onScrollHandler(event: Event): void {
if (!window)
return;
setArrivedState();
isScrolling.value = true;
onScrollEndDebounced(event);
onScroll(event);
}
useEventListener(
window,
'scroll',
throttle ? useThrottleFn(onScrollHandler, throttle, true, false) : onScrollHandler,
eventListenerOptions,
);
useEventListener(
window,
'scrollend',
onScrollEnd,
eventListenerOptions,
);
tryOnMounted(setArrivedState);
return {
x,
y,
isScrolling,
arrivedState,
directions,
measure: setArrivedState,
};
}
@@ -0,0 +1,119 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick } from 'vue';
import { useWindowSize } from '.';
describe(useWindowSize, () => {
beforeEach(() => {
vi.stubGlobal('matchMedia', undefined);
window.innerWidth = 1024;
window.innerHeight = 768;
});
afterEach(() => vi.unstubAllGlobals());
it('reads the current window size', () => {
const scope = effectScope();
let size: ReturnType<typeof useWindowSize>;
scope.run(() => {
size = useWindowSize({ listenOrientation: false });
});
expect(size!.width.value).toBe(1024);
expect(size!.height.value).toBe(768);
scope.stop();
});
it('updates on resize', async () => {
const scope = effectScope();
let size: ReturnType<typeof useWindowSize>;
scope.run(() => {
size = useWindowSize({ listenOrientation: false });
});
window.innerWidth = 500;
window.innerHeight = 400;
globalThis.dispatchEvent(new Event('resize'));
await nextTick();
expect(size!.width.value).toBe(500);
expect(size!.height.value).toBe(400);
scope.stop();
});
it('uses documentElement client size when includeScrollbar is false', () => {
Object.defineProperty(globalThis.document.documentElement, 'clientWidth', {
configurable: true,
value: 1000,
});
Object.defineProperty(globalThis.document.documentElement, 'clientHeight', {
configurable: true,
value: 700,
});
const scope = effectScope();
let size: ReturnType<typeof useWindowSize>;
scope.run(() => {
size = useWindowSize({ listenOrientation: false, includeScrollbar: false });
});
expect(size!.width.value).toBe(1000);
expect(size!.height.value).toBe(700);
scope.stop();
});
it('reads outer window size for type "outer"', () => {
window.outerWidth = 1440;
window.outerHeight = 900;
const scope = effectScope();
let size: ReturnType<typeof useWindowSize>;
scope.run(() => {
size = useWindowSize({ listenOrientation: false, type: 'outer' });
});
expect(size!.width.value).toBe(1440);
expect(size!.height.value).toBe(900);
scope.stop();
});
it('reads scaled visual viewport size for type "visual"', async () => {
const visualViewport = {
width: 800,
height: 600,
scale: 1.5,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
Object.defineProperty(globalThis, 'visualViewport', {
configurable: true,
value: visualViewport,
});
const scope = effectScope();
let size: ReturnType<typeof useWindowSize>;
scope.run(() => {
size = useWindowSize({ listenOrientation: false, type: 'visual' });
});
await nextTick();
expect(size!.width.value).toBe(1200);
expect(size!.height.value).toBe(900);
expect(visualViewport.addEventListener).toHaveBeenCalledWith('resize', expect.any(Function), expect.objectContaining({ passive: true }));
scope.stop();
Object.defineProperty(globalThis, 'visualViewport', { configurable: true, value: undefined });
});
it('falls back to inner size for type "visual" without visualViewport', () => {
Object.defineProperty(globalThis, 'visualViewport', { configurable: true, value: undefined });
const scope = effectScope();
let size: ReturnType<typeof useWindowSize>;
scope.run(() => {
size = useWindowSize({ listenOrientation: false, type: 'visual' });
});
expect(size!.width.value).toBe(1024);
expect(size!.height.value).toBe(768);
scope.stop();
});
});
@@ -0,0 +1,135 @@
import { shallowRef, watch } from 'vue';
import type { ShallowRef } from 'vue';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { useEventListener } from '@/composables/browser/useEventListener';
import { useMediaQuery } from '@/composables/browser/useMediaQuery';
import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted';
/**
* Which window dimensions to track.
*
* - `'inner'` — `window.innerWidth/innerHeight` (or `documentElement.clientWidth/clientHeight`
* when `includeScrollbar` is `false`). The viewport size.
* - `'outer'` — `window.outerWidth/outerHeight`. The whole browser window, including chrome.
* - `'visual'` — `window.visualViewport` size, accounting for pinch-zoom scale. Useful on
* mobile where the visual viewport differs from the layout viewport.
*/
export type WindowSizeType = 'inner' | 'outer' | 'visual';
export interface UseWindowSizeOptions extends ConfigurableWindow {
/**
* The initial width, used before the window is available (e.g. during SSR).
*
* @default Number.POSITIVE_INFINITY
*/
initialWidth?: number;
/**
* The initial height, used before the window is available (e.g. during SSR).
*
* @default Number.POSITIVE_INFINITY
*/
initialHeight?: number;
/**
* Listen to orientation changes via a `(orientation: portrait)` media query.
*
* @default true
*/
listenOrientation?: boolean;
/**
* Use `window.innerWidth/innerHeight` (includes scrollbar) instead of
* `documentElement.clientWidth/clientHeight`. Only affects the `'inner'` type.
*
* @default true
*/
includeScrollbar?: boolean;
/**
* Which window dimensions to track.
*
* @default 'inner'
*/
type?: WindowSizeType;
}
export interface UseWindowSizeReturn {
width: ShallowRef<number>;
height: ShallowRef<number>;
}
/**
* @name useWindowSize
* @category Elements
* @description Reactive window size. Tracks the inner viewport, the outer window, or the
* visual viewport (pinch-zoom aware), and reacts to resize and orientation changes.
*
* @param {UseWindowSizeOptions} [options={}] Options
* @returns {UseWindowSizeReturn} Reactive `width` and `height`
*
* @example
* const { width, height } = useWindowSize();
*
* @example
* // Track the pinch-zoom aware visual viewport on mobile
* const { width, height } = useWindowSize({ type: 'visual' });
*
* @since 0.0.15
*/
export function useWindowSize(options: UseWindowSizeOptions = {}): UseWindowSizeReturn {
const {
window = defaultWindow,
initialWidth = Number.POSITIVE_INFINITY,
initialHeight = Number.POSITIVE_INFINITY,
listenOrientation = true,
includeScrollbar = true,
type = 'inner',
} = options;
const width = shallowRef(initialWidth);
const height = shallowRef(initialHeight);
const update = (): void => {
if (!window)
return;
if (type === 'outer') {
width.value = window.outerWidth;
height.value = window.outerHeight;
}
else if (type === 'visual' && window.visualViewport) {
const { width: visualWidth, height: visualHeight, scale } = window.visualViewport;
width.value = Math.round(visualWidth * scale);
height.value = Math.round(visualHeight * scale);
}
else if (includeScrollbar) {
width.value = window.innerWidth;
height.value = window.innerHeight;
}
else {
width.value = window.document.documentElement.clientWidth;
height.value = window.document.documentElement.clientHeight;
}
};
update();
tryOnMounted(update);
const listenerOptions = { passive: true } as const;
useEventListener('resize', update, listenerOptions);
// Reactive getter target: auto-binds when `visualViewport` becomes available and
// is a no-op otherwise (SSR / unsupported), without recreating listeners.
if (type === 'visual')
useEventListener(() => window?.visualViewport, 'resize', update, listenerOptions);
if (listenOrientation) {
const orientation = useMediaQuery('(orientation: portrait)', { window });
watch(orientation, update);
}
return { width, height };
}