feat(vue): expand @robonen/vue composable collection

Composables, tests, category barrels, and README for @robonen/vue.
This commit is contained in:
2026-06-08 15:51:16 +07:00
parent 9a912f7a77
commit 59e995d0b5
369 changed files with 36554 additions and 188 deletions
@@ -0,0 +1,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,
};
}