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