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