feat(vue): expand @robonen/vue composable collection
Composables, tests, category barrels, and README for @robonen/vue.
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { defineComponent, effectScope, nextTick, watch } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { broadcastedRef } from '.';
|
||||
|
||||
type MessageHandler = ((event: MessageEvent) => void) | null;
|
||||
|
||||
class MockBroadcastChannel {
|
||||
static instances: MockBroadcastChannel[] = [];
|
||||
|
||||
name: string;
|
||||
onmessage: MessageHandler = null;
|
||||
closed = false;
|
||||
|
||||
constructor(name: string) {
|
||||
this.name = name;
|
||||
MockBroadcastChannel.instances.push(this);
|
||||
}
|
||||
|
||||
postMessage(data: unknown) {
|
||||
if (this.closed) return;
|
||||
|
||||
for (const instance of MockBroadcastChannel.instances) {
|
||||
if (instance !== this && instance.name === this.name && !instance.closed && instance.onmessage) {
|
||||
instance.onmessage(new MessageEvent('message', { data }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
const index = MockBroadcastChannel.instances.indexOf(this);
|
||||
if (index > -1) MockBroadcastChannel.instances.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
const mountWithRef = (setup: () => Record<string, any> | void) => {
|
||||
return mount(
|
||||
defineComponent({
|
||||
setup,
|
||||
template: '<div></div>',
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
describe(broadcastedRef, () => {
|
||||
let component: ReturnType<typeof mountWithRef>;
|
||||
|
||||
beforeEach(() => {
|
||||
MockBroadcastChannel.instances = [];
|
||||
vi.stubGlobal('BroadcastChannel', MockBroadcastChannel);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
component?.unmount();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('create a ref with the initial value', () => {
|
||||
component = mountWithRef(() => {
|
||||
const count = broadcastedRef('test-key', 42);
|
||||
expect(count.value).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcast value changes to other channels with the same key', () => {
|
||||
const ref1 = broadcastedRef('shared', 0);
|
||||
const ref2 = broadcastedRef('shared', 0);
|
||||
|
||||
ref1.value = 100;
|
||||
|
||||
expect(ref2.value).toBe(100);
|
||||
});
|
||||
|
||||
it('not broadcast to channels with a different key', () => {
|
||||
const ref1 = broadcastedRef('key-a', 0);
|
||||
const ref2 = broadcastedRef('key-b', 0);
|
||||
|
||||
ref1.value = 100;
|
||||
|
||||
expect(ref2.value).toBe(0);
|
||||
});
|
||||
|
||||
it('receive values from other channels and trigger reactivity', async () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
component = mountWithRef(() => {
|
||||
const data = broadcastedRef('reactive-test', 'initial');
|
||||
watch(data, callback, { flush: 'sync' });
|
||||
});
|
||||
|
||||
const sender = broadcastedRef('reactive-test', '');
|
||||
sender.value = 'updated';
|
||||
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
expect(callback).toHaveBeenCalledWith('updated', 'initial', expect.anything());
|
||||
});
|
||||
|
||||
it('not broadcast initial value by default', () => {
|
||||
const ref1 = broadcastedRef('no-immediate', 'first');
|
||||
const ref2 = broadcastedRef('no-immediate', 'second');
|
||||
|
||||
expect(ref1.value).toBe('first');
|
||||
expect(ref2.value).toBe('second');
|
||||
});
|
||||
|
||||
it('broadcast initial value when immediate is true', () => {
|
||||
const ref1 = broadcastedRef('immediate-test', 'existing');
|
||||
broadcastedRef('immediate-test', 'new-value', { immediate: true });
|
||||
|
||||
expect(ref1.value).toBe('new-value');
|
||||
});
|
||||
|
||||
it('close channel on scope dispose', () => {
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
broadcastedRef('dispose-test', 0);
|
||||
});
|
||||
|
||||
expect(MockBroadcastChannel.instances).toHaveLength(1);
|
||||
|
||||
scope.stop();
|
||||
|
||||
expect(MockBroadcastChannel.instances).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handle complex object values via structured clone', () => {
|
||||
const ref1 = broadcastedRef('object-test', { status: 'pending', amount: 0 });
|
||||
const ref2 = broadcastedRef('object-test', { status: 'pending', amount: 0 });
|
||||
|
||||
ref1.value = { status: 'paid', amount: 99.99 };
|
||||
|
||||
expect(ref2.value).toEqual({ status: 'paid', amount: 99.99 });
|
||||
});
|
||||
|
||||
it('fallback to a regular ref when BroadcastChannel is not available', () => {
|
||||
vi.stubGlobal('BroadcastChannel', undefined);
|
||||
|
||||
const data = broadcastedRef('fallback', 'value');
|
||||
|
||||
expect(data.value).toBe('value');
|
||||
|
||||
data.value = 'updated';
|
||||
expect(data.value).toBe('updated');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { customRef, ref } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import { defaultWindow } from '@/types';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
|
||||
export interface BroadcastedRefOptions {
|
||||
/**
|
||||
* Immediately broadcast the initial value to other tabs on creation
|
||||
* @default false
|
||||
*/
|
||||
immediate?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name broadcastedRef
|
||||
* @category Browser
|
||||
* @description Creates a custom ref that syncs its value across browser tabs via the BroadcastChannel API
|
||||
*
|
||||
* @param {string} key The channel key to use for broadcasting
|
||||
* @param {T} initialValue The initial value of the ref
|
||||
* @param {BroadcastedRefOptions} [options={}] Options
|
||||
* @returns {Ref<T>} A custom ref that broadcasts value changes across tabs
|
||||
*
|
||||
* @example
|
||||
* const count = broadcastedRef('counter', 0);
|
||||
*
|
||||
* @example
|
||||
* const state = broadcastedRef('payment-status', { status: 'pending' });
|
||||
*
|
||||
* @since 0.0.13
|
||||
*/
|
||||
export function broadcastedRef<T>(key: string, initialValue: T, options: BroadcastedRefOptions = {}): Ref<T> {
|
||||
const { immediate = false } = options;
|
||||
|
||||
if (!defaultWindow || typeof BroadcastChannel === 'undefined') {
|
||||
return ref(initialValue) as Ref<T>;
|
||||
}
|
||||
|
||||
const channel = new BroadcastChannel(key);
|
||||
let value = initialValue;
|
||||
|
||||
const data = customRef<T>((track, trigger) => {
|
||||
channel.onmessage = (event: MessageEvent<T>) => {
|
||||
value = event.data;
|
||||
trigger();
|
||||
};
|
||||
|
||||
return {
|
||||
get() {
|
||||
track();
|
||||
return value;
|
||||
},
|
||||
set(newValue: T) {
|
||||
value = newValue;
|
||||
channel.postMessage(newValue);
|
||||
trigger();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
if (immediate) {
|
||||
channel.postMessage(initialValue);
|
||||
}
|
||||
|
||||
tryOnScopeDispose(() => channel.close());
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -1,58 +1,36 @@
|
||||
export * from './onKeyStroke';
|
||||
export * from './useActiveElement';
|
||||
export * from './useBodyScrollLock';
|
||||
export * from './useClickOutside';
|
||||
export * from './broadcastedRef';
|
||||
export * from './useBreakpoints';
|
||||
export * from './useClipboard';
|
||||
export * from './useClipboardItems';
|
||||
export * from './useCloseWatcher';
|
||||
export * from './useColorMode';
|
||||
export * from './useDevicePixelRatio';
|
||||
export * from './useDocumentReadyState';
|
||||
export * from './useDocumentVisibility';
|
||||
export * from './useDropZone';
|
||||
export * from './useElementBounding';
|
||||
export * from './useElementHover';
|
||||
export * from './useElementSize';
|
||||
export * from './useElementVisibility';
|
||||
export * from './useEscapeKey';
|
||||
export * from './useCssVar';
|
||||
export * from './useDark';
|
||||
export * from './useDocumentPiP';
|
||||
export * from './useEventListener';
|
||||
export * from './useEyeDropper';
|
||||
export * from './useFavicon';
|
||||
export * from './useFileDialog';
|
||||
export * from './useFocus';
|
||||
export * from './useFocusGuard';
|
||||
export * from './useFocusWithin';
|
||||
export * from './useFps';
|
||||
export * from './useFileSystemAccess';
|
||||
export * from './useFullscreen';
|
||||
export * from './useGeolocation';
|
||||
export * from './useIdle';
|
||||
export * from './useIntersectionObserver';
|
||||
export * from './useIntervalFn';
|
||||
export * from './useKeyModifier';
|
||||
export * from './useMagicKeys';
|
||||
export * from './useImage';
|
||||
export * from './useLocalFonts';
|
||||
export * from './useMediaQuery';
|
||||
export * from './useMouse';
|
||||
export * from './useMousePressed';
|
||||
export * from './useMutationObserver';
|
||||
export * from './useNetwork';
|
||||
export * from './useObjectUrl';
|
||||
export * from './useOnline';
|
||||
export * from './usePageLeave';
|
||||
export * from './usePermission';
|
||||
export * from './usePointer';
|
||||
export * from './usePreferredColorScheme';
|
||||
export * from './usePreferredContrast';
|
||||
export * from './usePreferredDark';
|
||||
export * from './useRafFn';
|
||||
export * from './useResizeObserver';
|
||||
export * from './useScreenOrientation';
|
||||
export * from './useScroll';
|
||||
export * from './useScrollLock';
|
||||
export * from './usePreferredLanguages';
|
||||
export * from './usePreferredReducedMotion';
|
||||
export * from './usePreferredReducedTransparency';
|
||||
export * from './useScriptTag';
|
||||
export * from './useShare';
|
||||
export * from './useSupported';
|
||||
export * from './useSwipe';
|
||||
export * from './useStyleTag';
|
||||
export * from './useTabLeader';
|
||||
export * from './useTextSelection';
|
||||
export * from './useTextareaAutosize';
|
||||
export * from './useTitle';
|
||||
export * from './useUrlSearchParams';
|
||||
export * from './useVibrate';
|
||||
export * from './useWindowFocus';
|
||||
export * from './useWindowScroll';
|
||||
export * from './useWindowSize';
|
||||
export * from './useWakeLock';
|
||||
export * from './useWebNotification';
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, ref } from 'vue';
|
||||
import { onKeyDown, onKeyPressed, onKeyStroke, onKeyUp } from '.';
|
||||
|
||||
function keydown(key: string, init: KeyboardEventInit = {}, target: EventTarget = globalThis) {
|
||||
target.dispatchEvent(new KeyboardEvent('keydown', { key, ...init }));
|
||||
}
|
||||
|
||||
describe(onKeyStroke, () => {
|
||||
let stops: Array<() => void> = [];
|
||||
|
||||
function track(stop: () => void) {
|
||||
stops.push(stop);
|
||||
return stop;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
stops.forEach(stop => stop());
|
||||
stops = [];
|
||||
});
|
||||
|
||||
it('fires the handler for a matching string key', () => {
|
||||
const handler = vi.fn();
|
||||
track(onKeyStroke('a', handler));
|
||||
|
||||
keydown('a');
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler.mock.calls[0]![0]).toBeInstanceOf(KeyboardEvent);
|
||||
});
|
||||
|
||||
it('ignores non-matching keys', () => {
|
||||
const handler = vi.fn();
|
||||
track(onKeyStroke('a', handler));
|
||||
|
||||
keydown('b');
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('matches any key in an array filter', () => {
|
||||
const handler = vi.fn();
|
||||
track(onKeyStroke(['a', 'b', 'c'], handler));
|
||||
|
||||
keydown('a');
|
||||
keydown('b');
|
||||
keydown('z');
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('uses a predicate filter', () => {
|
||||
const handler = vi.fn();
|
||||
track(onKeyStroke((e: KeyboardEvent) => e.metaKey && e.key === 's', handler));
|
||||
|
||||
keydown('s');
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
|
||||
keydown('s', { metaKey: true });
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('matches every key when filter is omitted', () => {
|
||||
const handler = vi.fn();
|
||||
track(onKeyStroke(handler));
|
||||
|
||||
keydown('a');
|
||||
keydown('b');
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('matches every key when filter is `true`', () => {
|
||||
const handler = vi.fn();
|
||||
track(onKeyStroke(true, handler));
|
||||
|
||||
keydown('x');
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('supports the handler-plus-options overload', () => {
|
||||
const handler = vi.fn();
|
||||
const target = document.createElement('div');
|
||||
track(onKeyStroke(handler, { target }));
|
||||
|
||||
keydown('a', {}, target);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
keydown('a');
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('attaches to a custom target', () => {
|
||||
const handler = vi.fn();
|
||||
const target = document.createElement('div');
|
||||
track(onKeyStroke('a', handler, { target }));
|
||||
|
||||
keydown('a', {}, target);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
keydown('a');
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('listens on a custom eventName', () => {
|
||||
const handler = vi.fn();
|
||||
const target = document.createElement('div');
|
||||
track(onKeyStroke('a', handler, { target, eventName: 'keyup' }));
|
||||
|
||||
keydown('a', {}, target);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
|
||||
target.dispatchEvent(new KeyboardEvent('keyup', { key: 'a' }));
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('dedupe ignores auto-repeated events', () => {
|
||||
const handler = vi.fn();
|
||||
track(onKeyStroke('a', handler, { dedupe: true }));
|
||||
|
||||
keydown('a', { repeat: false });
|
||||
keydown('a', { repeat: true });
|
||||
keydown('a', { repeat: true });
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('dedupe accepts a reactive ref', () => {
|
||||
const handler = vi.fn();
|
||||
const dedupe = ref(false);
|
||||
track(onKeyStroke('a', handler, { dedupe }));
|
||||
|
||||
keydown('a', { repeat: true });
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
dedupe.value = true;
|
||||
keydown('a', { repeat: true });
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('without dedupe still fires for repeated events', () => {
|
||||
const handler = vi.fn();
|
||||
track(onKeyStroke('a', handler));
|
||||
|
||||
keydown('a', { repeat: true });
|
||||
keydown('a', { repeat: true });
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('stop handle removes the listener', () => {
|
||||
const handler = vi.fn();
|
||||
const stop = onKeyStroke('a', handler);
|
||||
|
||||
stop();
|
||||
keydown('a');
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cleans up when the effect scope is disposed', () => {
|
||||
const handler = vi.fn();
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
onKeyStroke('a', handler);
|
||||
});
|
||||
|
||||
scope.stop();
|
||||
keydown('a');
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns a no-op when target is unavailable (SSR / unsupported)', () => {
|
||||
const handler = vi.fn();
|
||||
const stop = onKeyStroke('a', handler, { target: null });
|
||||
|
||||
expect(typeof stop).toBe('function');
|
||||
keydown('a');
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
// stop should be safely callable
|
||||
expect(() => stop()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe(onKeyDown, () => {
|
||||
let stop: (() => void) | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
stop?.();
|
||||
stop = undefined;
|
||||
});
|
||||
|
||||
it('listens for keydown', () => {
|
||||
const handler = vi.fn();
|
||||
const target = document.createElement('div');
|
||||
stop = onKeyDown('a', handler, { target });
|
||||
|
||||
keydown('a', {}, target);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe(onKeyUp, () => {
|
||||
let stop: (() => void) | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
stop?.();
|
||||
stop = undefined;
|
||||
});
|
||||
|
||||
it('listens for keyup', () => {
|
||||
const handler = vi.fn();
|
||||
const target = document.createElement('div');
|
||||
stop = onKeyUp('a', handler, { target });
|
||||
|
||||
keydown('a', {}, target);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
|
||||
target.dispatchEvent(new KeyboardEvent('keyup', { key: 'a' }));
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe(onKeyPressed, () => {
|
||||
let stop: (() => void) | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
stop?.();
|
||||
stop = undefined;
|
||||
});
|
||||
|
||||
it('listens for keypress', () => {
|
||||
const handler = vi.fn();
|
||||
const target = document.createElement('div');
|
||||
stop = onKeyPressed('a', handler, { target });
|
||||
|
||||
keydown('a', {}, target);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
|
||||
target.dispatchEvent(new KeyboardEvent('keypress', { key: 'a' }));
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,197 +0,0 @@
|
||||
import type { VoidFunction } from '@robonen/stdlib';
|
||||
import { isArray, isFunction, isString, noop } from '@robonen/stdlib';
|
||||
import { toValue } from 'vue';
|
||||
import type { MaybeRefOrGetter } from 'vue';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
import { defaultWindow } from '@/types';
|
||||
|
||||
export type KeyPredicate = (event: KeyboardEvent) => boolean;
|
||||
export type KeyFilter = true | string | string[] | KeyPredicate;
|
||||
export type KeyStrokeEventName = 'keydown' | 'keypress' | 'keyup';
|
||||
|
||||
export interface OnKeyStrokeOptions {
|
||||
/**
|
||||
* The keyboard event to listen for.
|
||||
*
|
||||
* @default 'keydown'
|
||||
*/
|
||||
eventName?: KeyStrokeEventName;
|
||||
|
||||
/**
|
||||
* The element to attach the listener to.
|
||||
*
|
||||
* @default window
|
||||
*/
|
||||
target?: MaybeRefOrGetter<EventTarget | null | undefined>;
|
||||
|
||||
/**
|
||||
* Register the listener as passive (cannot call `preventDefault`).
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
passive?: boolean;
|
||||
|
||||
/**
|
||||
* Ignore auto-repeated keydown events while a key is held down.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
dedupe?: MaybeRefOrGetter<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a predicate from a key filter.
|
||||
*
|
||||
* - `function` → used as-is.
|
||||
* - `string` → matches `event.key`.
|
||||
* - `string[]` → matches any key in the list.
|
||||
* - `true`/anything else → matches every event.
|
||||
*/
|
||||
function createKeyPredicate(keyFilter: KeyFilter): KeyPredicate {
|
||||
if (isFunction(keyFilter))
|
||||
return keyFilter;
|
||||
|
||||
if (isString(keyFilter))
|
||||
return (event: KeyboardEvent) => event.key === keyFilter;
|
||||
|
||||
if (isArray(keyFilter))
|
||||
return (event: KeyboardEvent) => keyFilter.includes(event.key);
|
||||
|
||||
return () => true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name onKeyStroke
|
||||
* @category Browser
|
||||
* @description Listen for keyboard strokes. Accepts a key, list of keys, or a predicate and
|
||||
* fires the handler for matching events. Auto-cleans up on scope dispose.
|
||||
*
|
||||
* Overload 1: Explicit key filter
|
||||
*/
|
||||
export function onKeyStroke(key: KeyFilter, handler: (event: KeyboardEvent) => void, options?: OnKeyStrokeOptions): VoidFunction;
|
||||
|
||||
/**
|
||||
* @name onKeyStroke
|
||||
* @category Browser
|
||||
* @description Listen for every keyboard stroke (no key filter).
|
||||
*
|
||||
* Overload 2: Omitted key filter (matches all keys)
|
||||
*
|
||||
* @param {(event: KeyboardEvent) => void} handler Callback invoked on a matching key event
|
||||
* @param {OnKeyStrokeOptions} [options] Listener configuration
|
||||
* @returns {VoidFunction} Stop handle that removes the listener
|
||||
*
|
||||
* @example
|
||||
* onKeyStroke('ArrowDown', (e) => { e.preventDefault(); });
|
||||
* onKeyStroke(['a', 'b', 'c'], (e) => console.log(e.key));
|
||||
* onKeyStroke((e) => e.metaKey && e.key === 's', save, { eventName: 'keydown' });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function onKeyStroke(handler: (event: KeyboardEvent) => void, options?: OnKeyStrokeOptions): VoidFunction;
|
||||
|
||||
export function onKeyStroke(...args: any[]): VoidFunction {
|
||||
let key: KeyFilter;
|
||||
let handler: (event: KeyboardEvent) => void;
|
||||
let options: OnKeyStrokeOptions = {};
|
||||
|
||||
if (args.length === 3) {
|
||||
key = args[0];
|
||||
handler = args[1];
|
||||
options = args[2];
|
||||
}
|
||||
else if (args.length === 2) {
|
||||
if (typeof args[1] === 'object') {
|
||||
key = true;
|
||||
handler = args[0];
|
||||
options = args[1];
|
||||
}
|
||||
else {
|
||||
key = args[0];
|
||||
handler = args[1];
|
||||
}
|
||||
}
|
||||
else {
|
||||
key = true;
|
||||
handler = args[0];
|
||||
}
|
||||
|
||||
const {
|
||||
target = defaultWindow,
|
||||
eventName = 'keydown',
|
||||
passive = false,
|
||||
dedupe = false,
|
||||
} = options;
|
||||
|
||||
if (!target)
|
||||
return noop;
|
||||
|
||||
const predicate = createKeyPredicate(key);
|
||||
|
||||
const listener = (event: KeyboardEvent) => {
|
||||
if (event.repeat && toValue(dedupe))
|
||||
return;
|
||||
|
||||
if (predicate(event))
|
||||
handler(event);
|
||||
};
|
||||
|
||||
return useEventListener(target, eventName, listener, { passive });
|
||||
}
|
||||
|
||||
/**
|
||||
* @name onKeyDown
|
||||
* @category Browser
|
||||
* @description Listen for `keydown` strokes. Shorthand for `onKeyStroke` with `eventName: 'keydown'`.
|
||||
*
|
||||
* @param {KeyFilter} key Key, list of keys, or predicate to match
|
||||
* @param {(event: KeyboardEvent) => void} handler Callback invoked on a matching key event
|
||||
* @param {Omit<OnKeyStrokeOptions, 'eventName'>} [options] Listener configuration
|
||||
* @returns {VoidFunction} Stop handle that removes the listener
|
||||
*
|
||||
* @example
|
||||
* onKeyDown('Enter', submit);
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function onKeyDown(key: KeyFilter, handler: (event: KeyboardEvent) => void, options: Omit<OnKeyStrokeOptions, 'eventName'> = {}): VoidFunction {
|
||||
return onKeyStroke(key, handler, { ...options, eventName: 'keydown' });
|
||||
}
|
||||
|
||||
/**
|
||||
* @name onKeyUp
|
||||
* @category Browser
|
||||
* @description Listen for `keyup` strokes. Shorthand for `onKeyStroke` with `eventName: 'keyup'`.
|
||||
*
|
||||
* @param {KeyFilter} key Key, list of keys, or predicate to match
|
||||
* @param {(event: KeyboardEvent) => void} handler Callback invoked on a matching key event
|
||||
* @param {Omit<OnKeyStrokeOptions, 'eventName'>} [options] Listener configuration
|
||||
* @returns {VoidFunction} Stop handle that removes the listener
|
||||
*
|
||||
* @example
|
||||
* onKeyUp('Escape', close);
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function onKeyUp(key: KeyFilter, handler: (event: KeyboardEvent) => void, options: Omit<OnKeyStrokeOptions, 'eventName'> = {}): VoidFunction {
|
||||
return onKeyStroke(key, handler, { ...options, eventName: 'keyup' });
|
||||
}
|
||||
|
||||
/**
|
||||
* @name onKeyPressed
|
||||
* @category Browser
|
||||
* @description Listen for `keypress` strokes. Shorthand for `onKeyStroke` with `eventName: 'keypress'`.
|
||||
*
|
||||
* @param {KeyFilter} key Key, list of keys, or predicate to match
|
||||
* @param {(event: KeyboardEvent) => void} handler Callback invoked on a matching key event
|
||||
* @param {Omit<OnKeyStrokeOptions, 'eventName'>} [options] Listener configuration
|
||||
* @returns {VoidFunction} Stop handle that removes the listener
|
||||
*
|
||||
* @example
|
||||
* onKeyPressed('a', type);
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function onKeyPressed(key: KeyFilter, handler: (event: KeyboardEvent) => void, options: Omit<OnKeyStrokeOptions, 'eventName'> = {}): VoidFunction {
|
||||
return onKeyStroke(key, handler, { ...options, eventName: 'keypress' });
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { effectScope, isReadonly, nextTick } from 'vue';
|
||||
import { useActiveElement } from '.';
|
||||
|
||||
describe(useActiveElement, () => {
|
||||
it('tracks the focused element', async () => {
|
||||
const input = document.createElement('input');
|
||||
document.body.appendChild(input);
|
||||
|
||||
const scope = effectScope();
|
||||
let active: ReturnType<typeof useActiveElement>;
|
||||
scope.run(() => {
|
||||
active = useActiveElement();
|
||||
});
|
||||
|
||||
input.focus();
|
||||
input.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||
await nextTick();
|
||||
|
||||
expect(active!.value).toBe(input);
|
||||
|
||||
scope.stop();
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it('returns a shallow ref (not readonly)', () => {
|
||||
const scope = effectScope();
|
||||
let active: ReturnType<typeof useActiveElement>;
|
||||
scope.run(() => {
|
||||
active = useActiveElement();
|
||||
});
|
||||
|
||||
// shallowRef is writable internally; ensure we did not return a readonly wrapper
|
||||
expect(isReadonly(active!)).toBeFalsy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reflects the active element on creation', () => {
|
||||
const input = document.createElement('input');
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
|
||||
const scope = effectScope();
|
||||
let active: ReturnType<typeof useActiveElement>;
|
||||
scope.run(() => {
|
||||
active = useActiveElement();
|
||||
});
|
||||
|
||||
// initial trigger should capture the already-focused element synchronously
|
||||
expect(active!.value).toBe(input);
|
||||
|
||||
scope.stop();
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it('traverses open shadow roots when deep', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const shadow = host.attachShadow({ mode: 'open' });
|
||||
const inner = document.createElement('input');
|
||||
shadow.appendChild(inner);
|
||||
|
||||
const scope = effectScope();
|
||||
let active: ReturnType<typeof useActiveElement>;
|
||||
scope.run(() => {
|
||||
active = useActiveElement();
|
||||
});
|
||||
|
||||
inner.focus();
|
||||
document.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||
await nextTick();
|
||||
|
||||
expect(active!.value).toBe(inner);
|
||||
|
||||
scope.stop();
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it('does not descend into shadow roots when deep is false', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const shadow = host.attachShadow({ mode: 'open' });
|
||||
const inner = document.createElement('input');
|
||||
shadow.appendChild(inner);
|
||||
|
||||
const scope = effectScope();
|
||||
let active: ReturnType<typeof useActiveElement>;
|
||||
scope.run(() => {
|
||||
active = useActiveElement({ deep: false });
|
||||
});
|
||||
|
||||
inner.focus();
|
||||
document.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||
await nextTick();
|
||||
|
||||
// with deep:false we stay at the shadow host instead of piercing it
|
||||
expect(active!.value).toBe(host);
|
||||
|
||||
scope.stop();
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it('resets when focus leaves the window (blur with no relatedTarget)', async () => {
|
||||
const input = document.createElement('input');
|
||||
document.body.appendChild(input);
|
||||
|
||||
const scope = effectScope();
|
||||
let active: ReturnType<typeof useActiveElement>;
|
||||
scope.run(() => {
|
||||
active = useActiveElement();
|
||||
});
|
||||
|
||||
input.focus();
|
||||
globalThis.dispatchEvent(new FocusEvent('focus'));
|
||||
await nextTick();
|
||||
expect(active!.value).toBe(input);
|
||||
|
||||
// simulate focus leaving the document entirely
|
||||
input.blur();
|
||||
globalThis.dispatchEvent(new FocusEvent('blur', { relatedTarget: null }));
|
||||
await nextTick();
|
||||
expect(active!.value).toBe(document.body);
|
||||
|
||||
scope.stop();
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it('ignores window blur when focus moves to another element (relatedTarget set)', async () => {
|
||||
const input = document.createElement('input');
|
||||
document.body.appendChild(input);
|
||||
|
||||
const scope = effectScope();
|
||||
let active: ReturnType<typeof useActiveElement>;
|
||||
scope.run(() => {
|
||||
active = useActiveElement();
|
||||
});
|
||||
|
||||
input.focus();
|
||||
await nextTick();
|
||||
expect(active!.value).toBe(input);
|
||||
|
||||
// blur carrying a relatedTarget means focus stayed within the page -> ignore
|
||||
const other = document.createElement('button');
|
||||
globalThis.dispatchEvent(new FocusEvent('blur', { relatedTarget: other }));
|
||||
await nextTick();
|
||||
expect(active!.value).toBe(input);
|
||||
|
||||
scope.stop();
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it('accepts a custom document via options', () => {
|
||||
const fakeEl = document.createElement('textarea');
|
||||
const fakeDocument = { activeElement: fakeEl } as unknown as Document;
|
||||
|
||||
const scope = effectScope();
|
||||
let active: ReturnType<typeof useActiveElement>;
|
||||
scope.run(() => {
|
||||
active = useActiveElement({ document: fakeDocument });
|
||||
});
|
||||
|
||||
expect(active!.value).toBe(fakeEl);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not throw and stays undefined when document has no active element', () => {
|
||||
// emulate an environment (e.g. SSR / detached document) with no focus
|
||||
const emptyDocument = { activeElement: null } as unknown as Document;
|
||||
|
||||
const scope = effectScope();
|
||||
let active: ReturnType<typeof useActiveElement>;
|
||||
scope.run(() => {
|
||||
// pass a real window so listeners attach without error, but a doc with no focus
|
||||
active = useActiveElement({ document: emptyDocument });
|
||||
});
|
||||
|
||||
expect(active!.value).toBeNull();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('re-evaluates when the active element is removed (triggerOnRemoval)', async () => {
|
||||
const input = document.createElement('input');
|
||||
document.body.appendChild(input);
|
||||
|
||||
const scope = effectScope();
|
||||
let active: ReturnType<typeof useActiveElement>;
|
||||
scope.run(() => {
|
||||
active = useActiveElement({ triggerOnRemoval: true });
|
||||
});
|
||||
|
||||
input.focus();
|
||||
await nextTick();
|
||||
expect(active!.value).toBe(input);
|
||||
|
||||
input.remove();
|
||||
// MutationObserver delivery is async; wait a microtask-ish tick
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
await nextTick();
|
||||
|
||||
expect(active!.value).toBe(document.body);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
import { shallowRef } from 'vue';
|
||||
import type { ShallowRef } from 'vue';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableDocument, ConfigurableWindow } from '@/types';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
import { useMutationObserver } from '@/composables/browser/useMutationObserver';
|
||||
|
||||
export interface UseActiveElementOptions extends ConfigurableWindow, ConfigurableDocument {
|
||||
/**
|
||||
* Search for the active element inside open shadow roots
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
deep?: boolean;
|
||||
/**
|
||||
* Re-evaluate the active element when it is removed from the DOM.
|
||||
* Uses a `MutationObserver` under the hood, so it is only enabled on demand.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
triggerOnRemoval?: boolean;
|
||||
}
|
||||
|
||||
export type UseActiveElementReturn<T extends HTMLElement = HTMLElement> = ShallowRef<T | null | undefined>;
|
||||
|
||||
/**
|
||||
* @name useActiveElement
|
||||
* @category Browser
|
||||
* @description Reactive `document.activeElement`, traversing open shadow roots.
|
||||
*
|
||||
* @param {UseActiveElementOptions} [options={}] Options
|
||||
* @returns {UseActiveElementReturn<T>} The currently focused element
|
||||
*
|
||||
* @example
|
||||
* const active = useActiveElement();
|
||||
*
|
||||
* @example
|
||||
* // keep tracking even if the focused node is detached from the DOM
|
||||
* const active = useActiveElement({ triggerOnRemoval: true });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useActiveElement<T extends HTMLElement>(
|
||||
options: UseActiveElementOptions = {},
|
||||
): UseActiveElementReturn<T> {
|
||||
const {
|
||||
window = defaultWindow,
|
||||
deep = true,
|
||||
triggerOnRemoval = false,
|
||||
} = options;
|
||||
|
||||
const document = options.document ?? window?.document;
|
||||
|
||||
const getDeepActiveElement = (): Element | null | undefined => {
|
||||
let element = document?.activeElement;
|
||||
|
||||
if (deep) {
|
||||
while (element?.shadowRoot)
|
||||
element = element.shadowRoot.activeElement;
|
||||
}
|
||||
|
||||
return element;
|
||||
};
|
||||
|
||||
const activeElement = shallowRef<T | null | undefined>();
|
||||
|
||||
const trigger = (): void => {
|
||||
activeElement.value = getDeepActiveElement() as T | null | undefined;
|
||||
};
|
||||
|
||||
if (window) {
|
||||
const listenerOptions = { capture: true, passive: true } as const;
|
||||
|
||||
// `focus` (capture) catches focus moving onto any element, including those
|
||||
// inside open shadow roots; `blur` with no `relatedTarget` resets the ref
|
||||
// when focus leaves the document/window entirely.
|
||||
useEventListener(
|
||||
window,
|
||||
'blur',
|
||||
(event: FocusEvent) => {
|
||||
if (event.relatedTarget !== null)
|
||||
return;
|
||||
|
||||
trigger();
|
||||
},
|
||||
listenerOptions,
|
||||
);
|
||||
useEventListener(window, 'focus', trigger, listenerOptions);
|
||||
}
|
||||
|
||||
if (triggerOnRemoval && document) {
|
||||
useMutationObserver(
|
||||
() => [document.body],
|
||||
(mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const removed of mutation.removedNodes) {
|
||||
if (removed === activeElement.value || removed.contains(activeElement.value as Node)) {
|
||||
trigger();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ window, childList: true, subtree: true },
|
||||
);
|
||||
}
|
||||
|
||||
trigger();
|
||||
|
||||
return activeElement;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { useBodyScrollLock } from '.';
|
||||
|
||||
describe(useBodyScrollLock, () => {
|
||||
afterEach(() => {
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.paddingRight = '';
|
||||
document.body.style.touchAction = '';
|
||||
});
|
||||
|
||||
it('locks body overflow', () => {
|
||||
const release = useBodyScrollLock();
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
release();
|
||||
});
|
||||
|
||||
it('restores original overflow after release', () => {
|
||||
document.body.style.overflow = 'auto';
|
||||
const release = useBodyScrollLock();
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
release();
|
||||
expect(document.body.style.overflow).toBe('auto');
|
||||
});
|
||||
|
||||
it('reference-counts concurrent holders', () => {
|
||||
const r1 = useBodyScrollLock();
|
||||
const r2 = useBodyScrollLock();
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
|
||||
r1();
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
|
||||
r2();
|
||||
expect(document.body.style.overflow).toBe('');
|
||||
});
|
||||
|
||||
it('release is idempotent', () => {
|
||||
const release = useBodyScrollLock();
|
||||
release();
|
||||
release();
|
||||
expect(document.body.style.overflow).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
import { isClient } from '@robonen/platform/multi';
|
||||
import type { VoidFunction } from '@robonen/stdlib';
|
||||
import { noop } from '@robonen/stdlib';
|
||||
|
||||
interface LockState {
|
||||
refs: number;
|
||||
originalOverflow: string;
|
||||
originalPaddingRight: string;
|
||||
originalTouchAction: string;
|
||||
}
|
||||
|
||||
let state: LockState | null = null;
|
||||
|
||||
function acquire(): VoidFunction {
|
||||
if (!isClient) return noop;
|
||||
|
||||
if (!state) {
|
||||
const { body, documentElement } = document;
|
||||
const scrollbarWidth = globalThis.innerWidth - documentElement.clientWidth;
|
||||
|
||||
state = {
|
||||
refs: 0,
|
||||
originalOverflow: body.style.overflow,
|
||||
originalPaddingRight: body.style.paddingRight,
|
||||
originalTouchAction: body.style.touchAction,
|
||||
};
|
||||
|
||||
body.style.overflow = 'hidden';
|
||||
body.style.touchAction = 'none';
|
||||
|
||||
// Compensate scrollbar removal to prevent layout shift
|
||||
if (scrollbarWidth > 0) {
|
||||
const computedPr = Number.parseInt(globalThis.getComputedStyle(body).paddingRight, 10) || 0;
|
||||
body.style.paddingRight = `${computedPr + scrollbarWidth}px`;
|
||||
}
|
||||
}
|
||||
|
||||
state.refs++;
|
||||
|
||||
let released = false;
|
||||
const release = () => {
|
||||
if (released || !state) return;
|
||||
released = true;
|
||||
state.refs--;
|
||||
|
||||
if (state.refs === 0) {
|
||||
document.body.style.overflow = state.originalOverflow;
|
||||
document.body.style.paddingRight = state.originalPaddingRight;
|
||||
document.body.style.touchAction = state.originalTouchAction;
|
||||
state = null;
|
||||
}
|
||||
};
|
||||
|
||||
tryOnScopeDispose(release);
|
||||
return release;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useBodyScrollLock
|
||||
* @category Browser
|
||||
* @description Reference-counted body scroll lock. Safe to invoke from multiple
|
||||
* concurrent modals — the lock releases only after all holders release. Preserves
|
||||
* the original overflow/padding/touch-action values and compensates for scrollbar
|
||||
* removal to prevent layout shift.
|
||||
*
|
||||
* @returns {VoidFunction} Release function. Idempotent — call once per acquire.
|
||||
*
|
||||
* @since 0.0.14
|
||||
*/
|
||||
export function useBodyScrollLock(): VoidFunction {
|
||||
return acquire();
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick, ref } from 'vue';
|
||||
import {
|
||||
breakpointsAntDesign,
|
||||
breakpointsBootstrapV5,
|
||||
breakpointsTailwind,
|
||||
breakpointsVuetifyV3,
|
||||
useBreakpoints,
|
||||
} from '.';
|
||||
|
||||
type Listener = (event: { matches: boolean }) => void;
|
||||
|
||||
interface StubMql {
|
||||
readonly matches: boolean;
|
||||
media: string;
|
||||
addEventListener: (type: string, cb: Listener) => void;
|
||||
removeEventListener: (type: string, cb: Listener) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A `matchMedia` stub backed by a mutable viewport width. It parses
|
||||
* `(min-width: Npx)` / `(max-width: Npx)` (optionally joined by `and`) so each
|
||||
* query evaluates against the current `width`. Calling `setWidth` re-dispatches
|
||||
* `change` to every live MediaQueryList, mimicking a real viewport resize.
|
||||
*/
|
||||
function stubViewport(initialWidth: number) {
|
||||
let width = initialWidth;
|
||||
const lists = new Set<{ media: string; matches: boolean; listeners: Set<Listener> }>();
|
||||
|
||||
function toPx(value: string): number {
|
||||
const n = Number.parseFloat(value);
|
||||
return /(?:em|rem)$/i.test(value) ? n * 16 : n;
|
||||
}
|
||||
|
||||
function evaluate(media: string): boolean {
|
||||
return media.split(' and ').every((part) => {
|
||||
const min = part.match(/min-width:\s*(-?\d+(?:\.\d+)?(?:px|r?em)?)/);
|
||||
const max = part.match(/max-width:\s*(-?\d+(?:\.\d+)?(?:px|r?em)?)/);
|
||||
|
||||
if (min) return width >= toPx(min[1]!);
|
||||
if (max) return width <= toPx(max[1]!);
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
const matchMedia = vi.fn((media: string): StubMql => {
|
||||
const entry = { media, matches: evaluate(media), listeners: new Set<Listener>() };
|
||||
lists.add(entry);
|
||||
|
||||
return {
|
||||
get matches() {
|
||||
return entry.matches;
|
||||
},
|
||||
media,
|
||||
addEventListener: (_: string, cb: Listener) => entry.listeners.add(cb),
|
||||
removeEventListener: (_: string, cb: Listener) => entry.listeners.delete(cb),
|
||||
};
|
||||
});
|
||||
|
||||
vi.stubGlobal('matchMedia', matchMedia);
|
||||
|
||||
return {
|
||||
matchMedia,
|
||||
setWidth(next: number) {
|
||||
width = next;
|
||||
|
||||
for (const entry of lists) {
|
||||
const matches = evaluate(entry.media);
|
||||
|
||||
if (matches !== entry.matches) {
|
||||
entry.matches = matches;
|
||||
|
||||
for (const cb of entry.listeners) cb({ matches });
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe(useBreakpoints, () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('matchMedia', undefined);
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('exposes a reactive shortcut ref per breakpoint (min-width strategy)', async () => {
|
||||
stubViewport(800);
|
||||
const scope = effectScope();
|
||||
let bp: ReturnType<typeof useBreakpoints<'sm' | 'md' | 'lg'>>;
|
||||
|
||||
scope.run(() => {
|
||||
bp = useBreakpoints({ sm: 640, md: 768, lg: 1024 });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
// 800px: >= sm (640), >= md (768), < lg (1024)
|
||||
expect(bp!.sm.value).toBeTruthy();
|
||||
expect(bp!.md.value).toBeTruthy();
|
||||
expect(bp!.lg.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('uses max-width semantics for shortcuts under the max-width strategy', async () => {
|
||||
stubViewport(800);
|
||||
const scope = effectScope();
|
||||
let bp: ReturnType<typeof useBreakpoints<'sm' | 'md' | 'lg'>>;
|
||||
|
||||
scope.run(() => {
|
||||
bp = useBreakpoints({ sm: 640, md: 768, lg: 1024 }, { strategy: 'max-width' });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
// 800px: <= lg (1024) only; not <= sm/md
|
||||
expect(bp!.sm.value).toBeFalsy();
|
||||
expect(bp!.md.value).toBeFalsy();
|
||||
expect(bp!.lg.value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reacts to viewport changes', async () => {
|
||||
const vp = stubViewport(500);
|
||||
const scope = effectScope();
|
||||
let bp: ReturnType<typeof useBreakpoints<'sm' | 'lg'>>;
|
||||
|
||||
scope.run(() => {
|
||||
bp = useBreakpoints({ sm: 640, lg: 1024 });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(bp!.greaterOrEqual('sm').value).toBeFalsy();
|
||||
|
||||
vp.setWidth(700);
|
||||
await nextTick();
|
||||
expect(bp!.greaterOrEqual('sm').value).toBeTruthy();
|
||||
expect(bp!.greaterOrEqual('lg').value).toBeFalsy();
|
||||
|
||||
vp.setWidth(1100);
|
||||
await nextTick();
|
||||
expect(bp!.greaterOrEqual('lg').value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('greater/smaller apply the strict (exclusive) delta', async () => {
|
||||
// Exactly at the md breakpoint (768).
|
||||
const vp = stubViewport(768);
|
||||
const scope = effectScope();
|
||||
let bp: ReturnType<typeof useBreakpoints<'md'>>;
|
||||
|
||||
scope.run(() => {
|
||||
bp = useBreakpoints({ md: 768 });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
// At exactly 768: greaterOrEqual true, greater false, smallerOrEqual true, smaller false.
|
||||
expect(bp!.greaterOrEqual('md').value).toBeTruthy();
|
||||
expect(bp!.greater('md').value).toBeFalsy();
|
||||
expect(bp!.smallerOrEqual('md').value).toBeTruthy();
|
||||
expect(bp!.smaller('md').value).toBeFalsy();
|
||||
|
||||
vp.setWidth(900);
|
||||
await nextTick();
|
||||
expect(bp!.greater('md').value).toBeTruthy();
|
||||
expect(bp!.smaller('md').value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('between is half-open [a, b)', async () => {
|
||||
const vp = stubViewport(768);
|
||||
const scope = effectScope();
|
||||
let bp: ReturnType<typeof useBreakpoints<'sm' | 'md' | 'lg'>>;
|
||||
|
||||
scope.run(() => {
|
||||
bp = useBreakpoints({ sm: 640, md: 768, lg: 1024 });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
// 768 is in [sm, lg) and in [md, lg); the upper bound (md) is exclusive in [sm, md).
|
||||
expect(bp!.between('sm', 'lg').value).toBeTruthy();
|
||||
expect(bp!.between('sm', 'md').value).toBeFalsy();
|
||||
expect(bp!.between('md', 'lg').value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('current() returns active breakpoints ordered small to large; active() picks per strategy', async () => {
|
||||
stubViewport(800);
|
||||
const scope = effectScope();
|
||||
let bp: ReturnType<typeof useBreakpoints<'sm' | 'md' | 'lg'>>;
|
||||
|
||||
scope.run(() => {
|
||||
bp = useBreakpoints({ lg: 1024, sm: 640, md: 768 });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
// 800px: sm and md active, sorted ascending.
|
||||
expect(bp!.current().value).toEqual(['sm', 'md']);
|
||||
// min-width strategy → largest active breakpoint.
|
||||
expect(bp!.active().value).toBe('md');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('active() picks the smallest active breakpoint under max-width strategy', async () => {
|
||||
stubViewport(800);
|
||||
const scope = effectScope();
|
||||
let bp: ReturnType<typeof useBreakpoints<'sm' | 'md' | 'lg'>>;
|
||||
|
||||
scope.run(() => {
|
||||
bp = useBreakpoints({ sm: 640, md: 768, lg: 1024 }, { strategy: 'max-width' });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(bp!.active().value).toBe('lg');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('synchronous is* helpers read the current match', async () => {
|
||||
stubViewport(800);
|
||||
const scope = effectScope();
|
||||
let bp: ReturnType<typeof useBreakpoints<'sm' | 'md' | 'lg'>>;
|
||||
|
||||
scope.run(() => {
|
||||
bp = useBreakpoints({ sm: 640, md: 768, lg: 1024 });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(bp!.isGreaterOrEqual('md')).toBeTruthy();
|
||||
expect(bp!.isGreater('md')).toBeTruthy();
|
||||
expect(bp!.isGreaterOrEqual('lg')).toBeFalsy();
|
||||
expect(bp!.isSmallerOrEqual('lg')).toBeTruthy();
|
||||
expect(bp!.isSmaller('sm')).toBeFalsy();
|
||||
expect(bp!.isInBetween('sm', 'lg')).toBeTruthy();
|
||||
expect(bp!.isInBetween('sm', 'md')).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('supports reactive breakpoint values', async () => {
|
||||
const vp = stubViewport(700);
|
||||
const threshold = ref(640);
|
||||
const scope = effectScope();
|
||||
let bp: ReturnType<typeof useBreakpoints<'point'>>;
|
||||
|
||||
scope.run(() => {
|
||||
bp = useBreakpoints({ point: threshold });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(bp!.greaterOrEqual('point').value).toBeTruthy();
|
||||
|
||||
threshold.value = 900;
|
||||
await nextTick();
|
||||
expect(bp!.greaterOrEqual('point').value).toBeFalsy();
|
||||
|
||||
vp.setWidth(1000);
|
||||
await nextTick();
|
||||
expect(bp!.greaterOrEqual('point').value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('handles string unit breakpoint values', async () => {
|
||||
stubViewport(800); // 800px, 48em = 768px
|
||||
const scope = effectScope();
|
||||
let bp: ReturnType<typeof useBreakpoints<'md'>>;
|
||||
|
||||
scope.run(() => {
|
||||
bp = useBreakpoints({ md: '48em' });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(bp!.greaterOrEqual('md').value).toBeTruthy();
|
||||
expect(bp!.isGreaterOrEqual('md')).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
describe('SSR / unsupported path', () => {
|
||||
it('resolves width queries from ssrWidth when matchMedia is unavailable', () => {
|
||||
// matchMedia stays undefined (beforeEach), window has no matchMedia path used by match().
|
||||
const scope = effectScope();
|
||||
let bp: ReturnType<typeof useBreakpoints<'sm' | 'lg'>>;
|
||||
|
||||
scope.run(() => {
|
||||
bp = useBreakpoints({ sm: 640, lg: 1024 }, { ssrWidth: 800 });
|
||||
});
|
||||
|
||||
// Synchronous helpers resolve against ssrWidth.
|
||||
expect(bp!.isGreaterOrEqual('sm')).toBeTruthy();
|
||||
expect(bp!.isGreaterOrEqual('lg')).toBeFalsy();
|
||||
expect(bp!.isSmallerOrEqual('lg')).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('returns false for snapshot helpers with no window and no ssrWidth', () => {
|
||||
const scope = effectScope();
|
||||
let bp: ReturnType<typeof useBreakpoints<'sm'>>;
|
||||
|
||||
scope.run(() => {
|
||||
bp = useBreakpoints({ sm: 640 }, { window: undefined });
|
||||
});
|
||||
|
||||
expect(bp!.isGreaterOrEqual('sm')).toBeFalsy();
|
||||
expect(bp!.isSmallerOrEqual('sm')).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not throw when constructed without a window (SSR)', () => {
|
||||
const scope = effectScope();
|
||||
|
||||
expect(() => {
|
||||
scope.run(() => {
|
||||
const bp = useBreakpoints(breakpointsTailwind, { window: undefined });
|
||||
// Accessing reactive refs should be safe and default to false.
|
||||
expect(bp.lg.value).toBeFalsy();
|
||||
});
|
||||
}).not.toThrow();
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe('presets', () => {
|
||||
it('exports the expected preset values', () => {
|
||||
expect(breakpointsTailwind).toMatchObject({ sm: 640, md: 768, lg: 1024, xl: 1280, '2xl': 1536 });
|
||||
expect(breakpointsBootstrapV5).toMatchObject({ xs: 0, sm: 576, md: 768, lg: 992, xl: 1200, xxl: 1400 });
|
||||
expect(breakpointsAntDesign).toMatchObject({ xs: 480, sm: 576, md: 768, lg: 992, xl: 1200, xxl: 1600 });
|
||||
expect(breakpointsVuetifyV3).toMatchObject({ xs: 0, sm: 600, md: 960, lg: 1280, xl: 1920, xxl: 2560 });
|
||||
});
|
||||
|
||||
it('works with a preset', async () => {
|
||||
stubViewport(1300);
|
||||
const scope = effectScope();
|
||||
let bp: ReturnType<typeof useBreakpoints<keyof typeof breakpointsTailwind & string>>;
|
||||
|
||||
scope.run(() => {
|
||||
bp = useBreakpoints(breakpointsTailwind);
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(bp!.greaterOrEqual('xl').value).toBeTruthy();
|
||||
expect(bp!.greaterOrEqual('2xl').value).toBeFalsy();
|
||||
expect(bp!.active().value).toBe('xl');
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
import { computed, toValue } from 'vue';
|
||||
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
|
||||
import { isFunction, isNumber } from '@robonen/stdlib';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { useMediaQuery } from '@/composables/browser/useMediaQuery';
|
||||
import type { UseMediaQueryOptions } from '@/composables/browser/useMediaQuery';
|
||||
|
||||
/**
|
||||
* A breakpoints map: name → viewport width. Numbers are treated as pixels;
|
||||
* strings keep their unit (`"48em"`, `"30rem"`, `"1024px"`). Values may be
|
||||
* reactive (refs or getters).
|
||||
*/
|
||||
export type Breakpoints<K extends string = string>
|
||||
= Record<K, MaybeRefOrGetter<number | string>>;
|
||||
|
||||
/**
|
||||
* Which edge a generated shortcut property (e.g. `breakpoints.lg`) reacts to.
|
||||
*
|
||||
* - `'min-width'` (mobile-first) — `lg` is `true` when the viewport is at least
|
||||
* the `lg` width.
|
||||
* - `'max-width'` (desktop-first) — `lg` is `true` when the viewport is at most
|
||||
* the `lg` width.
|
||||
*/
|
||||
export type UseBreakpointsStrategy = 'min-width' | 'max-width';
|
||||
|
||||
export interface UseBreakpointsOptions extends ConfigurableWindow, Pick<UseMediaQueryOptions, 'ssrWidth'> {
|
||||
/**
|
||||
* The query strategy used by the generated shortcut properties.
|
||||
*
|
||||
* @default 'min-width'
|
||||
*/
|
||||
strategy?: UseBreakpointsStrategy;
|
||||
}
|
||||
|
||||
export type UseBreakpointsReturn<K extends string = string>
|
||||
= Record<K, ComputedRef<boolean>> & {
|
||||
/** Reactive: viewport width is greater than or equal to breakpoint `k` (`min-width`). */
|
||||
greaterOrEqual: (k: MaybeRefOrGetter<K>) => ComputedRef<boolean>;
|
||||
/** Reactive: viewport width is smaller than or equal to breakpoint `k` (`max-width`). */
|
||||
smallerOrEqual: (k: MaybeRefOrGetter<K>) => ComputedRef<boolean>;
|
||||
/** Reactive: viewport width is strictly greater than breakpoint `k`. */
|
||||
greater: (k: MaybeRefOrGetter<K>) => ComputedRef<boolean>;
|
||||
/** Reactive: viewport width is strictly smaller than breakpoint `k`. */
|
||||
smaller: (k: MaybeRefOrGetter<K>) => ComputedRef<boolean>;
|
||||
/** Reactive: viewport width is within `[a, b)`. */
|
||||
between: (a: MaybeRefOrGetter<K>, b: MaybeRefOrGetter<K>) => ComputedRef<boolean>;
|
||||
/** Snapshot: viewport width is strictly greater than breakpoint `k`. */
|
||||
isGreater: (k: MaybeRefOrGetter<K>) => boolean;
|
||||
/** Snapshot: viewport width is greater than or equal to breakpoint `k`. */
|
||||
isGreaterOrEqual: (k: MaybeRefOrGetter<K>) => boolean;
|
||||
/** Snapshot: viewport width is strictly smaller than breakpoint `k`. */
|
||||
isSmaller: (k: MaybeRefOrGetter<K>) => boolean;
|
||||
/** Snapshot: viewport width is smaller than or equal to breakpoint `k`. */
|
||||
isSmallerOrEqual: (k: MaybeRefOrGetter<K>) => boolean;
|
||||
/** Snapshot: viewport width is within `[a, b)`. */
|
||||
isInBetween: (a: MaybeRefOrGetter<K>, b: MaybeRefOrGetter<K>) => boolean;
|
||||
/** Reactive: all currently active breakpoints, ordered small → large. */
|
||||
current: () => ComputedRef<K[]>;
|
||||
/** Reactive: the single active breakpoint per `strategy` (largest for `min-width`, smallest for `max-width`), or `''` when none. */
|
||||
active: () => ComputedRef<K | ''>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a CSS length token (`"1024px"`, `"48em"`, `"30rem"`, `"50%"`) into a
|
||||
* pixel number. `em`/`rem` use the conventional 16px root size.
|
||||
*/
|
||||
function pxValue(value: string): number {
|
||||
const number = Number.parseFloat(value);
|
||||
|
||||
if (Number.isNaN(number))
|
||||
return Number.NaN;
|
||||
|
||||
if (/(?:em|rem)\s*$/i.test(value))
|
||||
return number * 16;
|
||||
|
||||
return number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add `delta` to the numeric portion of a CSS length, preserving its unit.
|
||||
* Used to build the strict (`> / <`) variants from inclusive media queries via
|
||||
* a small ±0.1 nudge.
|
||||
*/
|
||||
function increaseWithUnit(target: number | string, delta: number): number | string {
|
||||
if (isNumber(target))
|
||||
return target + delta;
|
||||
|
||||
const value = target.match(/^-?\d+(?:\.\d+)?/)?.[0] ?? '';
|
||||
const unit = target.slice(value.length);
|
||||
const result = Number.parseFloat(value) + delta;
|
||||
|
||||
if (Number.isNaN(result))
|
||||
return target;
|
||||
|
||||
return result + unit;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useBreakpoints
|
||||
* @category Browser
|
||||
* @description Reactive viewport breakpoints derived from a breakpoints map.
|
||||
* SSR-safe (resolves width queries from `ssrWidth` before `matchMedia` exists),
|
||||
* reactive to breakpoint values, and built on a single `useMediaQuery` per
|
||||
* comparison. Comes with presets: `breakpointsTailwind`, `breakpointsBootstrapV5`,
|
||||
* `breakpointsAntDesign`, `breakpointsVuetifyV3`.
|
||||
*
|
||||
* @param {Breakpoints<K>} breakpoints The breakpoints map (`name → width`)
|
||||
* @param {UseBreakpointsOptions} [options={}] Options (`strategy`, custom `window`, `ssrWidth`)
|
||||
* @returns {UseBreakpointsReturn<K>} Shortcut refs per breakpoint plus comparison helpers
|
||||
*
|
||||
* @example
|
||||
* const bp = useBreakpoints(breakpointsTailwind);
|
||||
* const isDesktop = bp.greaterOrEqual('lg');
|
||||
* const isMobile = bp.smaller('md');
|
||||
* bp.lg; // ComputedRef<boolean> — true when viewport >= 1024px
|
||||
*
|
||||
* @example
|
||||
* const bp = useBreakpoints({ mobile: 0, tablet: 640, desktop: 1024 });
|
||||
* const active = bp.active(); // ComputedRef<'mobile' | 'tablet' | 'desktop' | ''>
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useBreakpoints<K extends string>(
|
||||
breakpoints: Breakpoints<K>,
|
||||
options: UseBreakpointsOptions = {},
|
||||
): UseBreakpointsReturn<K> {
|
||||
const { window = defaultWindow, strategy = 'min-width', ssrWidth } = options;
|
||||
const mediaOptions: UseMediaQueryOptions = { window, ssrWidth };
|
||||
const ssrSupport = isNumber(ssrWidth);
|
||||
|
||||
function getValue(k: MaybeRefOrGetter<K>, delta?: number): string {
|
||||
let v = toValue(breakpoints[toValue(k)]);
|
||||
|
||||
if (delta !== undefined)
|
||||
v = increaseWithUnit(v, delta);
|
||||
|
||||
return isNumber(v) ? `${v}px` : v;
|
||||
}
|
||||
|
||||
// Synchronous (non-reactive) match for the `is*` snapshot helpers.
|
||||
function match(edge: 'min' | 'max', size: string): boolean {
|
||||
const supported = window && isFunction(window.matchMedia);
|
||||
|
||||
if (!supported)
|
||||
return ssrSupport
|
||||
? (edge === 'min' ? ssrWidth >= pxValue(size) : ssrWidth <= pxValue(size))
|
||||
: false;
|
||||
|
||||
return window.matchMedia(`(${edge}-width: ${size})`).matches;
|
||||
}
|
||||
|
||||
const greaterOrEqual = (k: MaybeRefOrGetter<K>): ComputedRef<boolean> =>
|
||||
useMediaQuery(() => `(min-width: ${getValue(k)})`, mediaOptions);
|
||||
|
||||
const smallerOrEqual = (k: MaybeRefOrGetter<K>): ComputedRef<boolean> =>
|
||||
useMediaQuery(() => `(max-width: ${getValue(k)})`, mediaOptions);
|
||||
|
||||
const greater = (k: MaybeRefOrGetter<K>): ComputedRef<boolean> =>
|
||||
useMediaQuery(() => `(min-width: ${getValue(k, 0.1)})`, mediaOptions);
|
||||
|
||||
const smaller = (k: MaybeRefOrGetter<K>): ComputedRef<boolean> =>
|
||||
useMediaQuery(() => `(max-width: ${getValue(k, -0.1)})`, mediaOptions);
|
||||
|
||||
const between = (a: MaybeRefOrGetter<K>, b: MaybeRefOrGetter<K>): ComputedRef<boolean> =>
|
||||
useMediaQuery(() => `(min-width: ${getValue(a)}) and (max-width: ${getValue(b, -0.1)})`, mediaOptions);
|
||||
|
||||
const keys = Object.keys(breakpoints) as K[];
|
||||
|
||||
// Generated shortcut properties (`bp.lg`). Lazily created getters so we only
|
||||
// spin up a `useMediaQuery` watcher for the breakpoints actually accessed.
|
||||
const shortcuts = keys.reduce((acc, k) => {
|
||||
Object.defineProperty(acc, k, {
|
||||
get: () => strategy === 'min-width' ? greaterOrEqual(k) : smallerOrEqual(k),
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
return acc;
|
||||
}, {} as Record<K, ComputedRef<boolean>>);
|
||||
|
||||
function current(): ComputedRef<K[]> {
|
||||
const points = keys
|
||||
.map(k => [k, shortcuts[k], pxValue(getValue(k))] as const)
|
||||
.sort((a, b) => a[2] - b[2]);
|
||||
|
||||
return computed(() => points.filter(([, matches]) => matches.value).map(([k]) => k));
|
||||
}
|
||||
|
||||
return Object.assign(shortcuts, {
|
||||
greaterOrEqual,
|
||||
smallerOrEqual,
|
||||
greater,
|
||||
smaller,
|
||||
between,
|
||||
isGreater: (k: MaybeRefOrGetter<K>): boolean => match('min', getValue(k, 0.1)),
|
||||
isGreaterOrEqual: (k: MaybeRefOrGetter<K>): boolean => match('min', getValue(k)),
|
||||
isSmaller: (k: MaybeRefOrGetter<K>): boolean => match('max', getValue(k, -0.1)),
|
||||
isSmallerOrEqual: (k: MaybeRefOrGetter<K>): boolean => match('max', getValue(k)),
|
||||
isInBetween: (a: MaybeRefOrGetter<K>, b: MaybeRefOrGetter<K>): boolean =>
|
||||
match('min', getValue(a)) && match('max', getValue(b, -0.1)),
|
||||
current,
|
||||
active(): ComputedRef<K | ''> {
|
||||
const bps = current();
|
||||
|
||||
return computed(() => bps.value.length === 0
|
||||
? ''
|
||||
: bps.value.at(strategy === 'min-width' ? -1 : 0)!);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tailwind CSS default breakpoints.
|
||||
*
|
||||
* @see https://tailwindcss.com/docs/responsive-design
|
||||
*/
|
||||
export const breakpointsTailwind = {
|
||||
sm: 640,
|
||||
md: 768,
|
||||
lg: 1024,
|
||||
xl: 1280,
|
||||
'2xl': 1536,
|
||||
};
|
||||
|
||||
/**
|
||||
* Bootstrap v5 default breakpoints.
|
||||
*
|
||||
* @see https://getbootstrap.com/docs/5.0/layout/breakpoints/
|
||||
*/
|
||||
export const breakpointsBootstrapV5 = {
|
||||
xs: 0,
|
||||
sm: 576,
|
||||
md: 768,
|
||||
lg: 992,
|
||||
xl: 1200,
|
||||
xxl: 1400,
|
||||
};
|
||||
|
||||
/**
|
||||
* Ant Design default breakpoints.
|
||||
*
|
||||
* @see https://ant.design/components/grid#col
|
||||
*/
|
||||
export const breakpointsAntDesign = {
|
||||
xs: 480,
|
||||
sm: 576,
|
||||
md: 768,
|
||||
lg: 992,
|
||||
xl: 1200,
|
||||
xxl: 1600,
|
||||
};
|
||||
|
||||
/**
|
||||
* Vuetify v3 default breakpoints.
|
||||
*
|
||||
* @see https://vuetifyjs.com/en/features/display-and-platform/
|
||||
*/
|
||||
export const breakpointsVuetifyV3 = {
|
||||
xs: 0,
|
||||
sm: 600,
|
||||
md: 960,
|
||||
lg: 1280,
|
||||
xl: 1920,
|
||||
xxl: 2560,
|
||||
};
|
||||
@@ -1,77 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { defineComponent, h, nextTick, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { useClickOutside } from '.';
|
||||
|
||||
function mountWithOutside(handler: (e: Event) => void) {
|
||||
const Comp = defineComponent({
|
||||
setup() {
|
||||
const target = ref<HTMLElement | null>(null);
|
||||
|
||||
return () => h('div', {
|
||||
ref: (el: any) => { target.value = el; },
|
||||
'data-testid': 'target',
|
||||
}, [
|
||||
h('button', { 'data-testid': 'inside' }, 'inside'),
|
||||
]);
|
||||
},
|
||||
mounted() {
|
||||
useClickOutside(() => this.$el, handler);
|
||||
},
|
||||
});
|
||||
|
||||
return mount(Comp, { attachTo: document.body });
|
||||
}
|
||||
|
||||
describe(useClickOutside, () => {
|
||||
it('invokes handler on outside pointerdown', async () => {
|
||||
const handler = vi.fn();
|
||||
const w = mountWithOutside(handler);
|
||||
|
||||
const outside = document.createElement('button');
|
||||
document.body.appendChild(outside);
|
||||
|
||||
await nextTick();
|
||||
outside.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true }));
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
outside.remove();
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('does not invoke handler on inside pointerdown', async () => {
|
||||
const handler = vi.fn();
|
||||
const w = mountWithOutside(handler);
|
||||
await nextTick();
|
||||
|
||||
const inside = w.find('[data-testid=inside]').element as HTMLElement;
|
||||
inside.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true }));
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('respects the ignore list', async () => {
|
||||
const handler = vi.fn();
|
||||
const ignored = document.createElement('div');
|
||||
document.body.appendChild(ignored);
|
||||
|
||||
const Comp = defineComponent({
|
||||
setup() {
|
||||
return () => h('div', { 'data-testid': 'target' }, 'target');
|
||||
},
|
||||
mounted() {
|
||||
useClickOutside(() => this.$el, handler, { ignore: [ignored] });
|
||||
},
|
||||
});
|
||||
|
||||
const w = mount(Comp, { attachTo: document.body });
|
||||
await nextTick();
|
||||
|
||||
ignored.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true }));
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
|
||||
ignored.remove();
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
||||
import { unrefElement } from '@/composables/component/unrefElement';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { MaybeRefOrGetter } from 'vue';
|
||||
import { toValue } from 'vue';
|
||||
import type { VoidFunction } from '@robonen/stdlib';
|
||||
import { noop } from '@robonen/stdlib';
|
||||
|
||||
export interface UseClickOutsideOptions {
|
||||
/**
|
||||
* Elements that are inside `target` semantically but physically rendered
|
||||
* elsewhere (e.g. portaled menus). Events originating in these nodes
|
||||
* are treated as *inside* clicks.
|
||||
*/
|
||||
ignore?: MaybeRefOrGetter<Array<MaybeComputedElementRef | undefined>>;
|
||||
|
||||
/**
|
||||
* Detect outside pointer-down instead of click. Useful for dismissable layers
|
||||
* that want to react as soon as the user starts interacting outside.
|
||||
* @default 'pointerdown'
|
||||
*/
|
||||
event?: 'pointerdown' | 'mousedown' | 'click';
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useClickOutside
|
||||
* @category Browser
|
||||
* @description Invokes `handler` when a pointer event occurs outside `target`.
|
||||
* SSR-safe: no-op on the server. Handles portaled/ignored subtrees and
|
||||
* guards against synthetic "outside" clicks on removed nodes.
|
||||
*
|
||||
* @param {MaybeComputedElementRef} target Element to watch. Events inside it are ignored.
|
||||
* @param {(event: PointerEvent | MouseEvent) => void} handler Callback invoked with the outside event
|
||||
* @param {UseClickOutsideOptions} [options] Options
|
||||
* @returns {VoidFunction} Stop handle to remove the listeners
|
||||
*
|
||||
* @since 0.0.14
|
||||
*/
|
||||
export function useClickOutside(
|
||||
target: MaybeComputedElementRef,
|
||||
handler: (event: PointerEvent | MouseEvent) => void,
|
||||
options: UseClickOutsideOptions = {},
|
||||
): VoidFunction {
|
||||
if (!defaultWindow) return noop;
|
||||
|
||||
const { event = 'pointerdown', ignore } = options;
|
||||
|
||||
const listener = (e: Event) => {
|
||||
const el = unrefElement(target) as HTMLElement | undefined;
|
||||
const pe = e as PointerEvent;
|
||||
const path = (e.composedPath?.() ?? []) as Node[];
|
||||
const eventTarget = (path[0] ?? e.target) as Node | null;
|
||||
|
||||
if (!el || !eventTarget) return;
|
||||
if (el === eventTarget || el.contains(eventTarget)) return;
|
||||
|
||||
const ignoreList = toValue(ignore) ?? [];
|
||||
for (const ref of ignoreList) {
|
||||
const node = unrefElement(ref) as HTMLElement | undefined;
|
||||
if (node && (node === eventTarget || node.contains(eventTarget))) return;
|
||||
}
|
||||
|
||||
handler(pe);
|
||||
};
|
||||
|
||||
return useEventListener(defaultWindow, event, listener, { passive: true, capture: true });
|
||||
}
|
||||
@@ -3,9 +3,9 @@ import type { MaybeRefOrGetter, Ref } from 'vue';
|
||||
import { isString } from '@robonen/stdlib';
|
||||
import { defaultNavigator } from '@/types';
|
||||
import type { ConfigurableNavigator } from '@/types';
|
||||
import { useSupported } from '@/composables/browser/useSupported';
|
||||
import { useSupported } from '@/composables/utilities/useSupported';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
import { useTimeoutFn } from '@/composables/utilities/useTimeoutFn';
|
||||
import { useTimeoutFn } from '@/composables/animation/useTimeoutFn';
|
||||
|
||||
/**
|
||||
* A value to copy: either a string or an (optionally async) getter that resolves to one.
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick } from 'vue';
|
||||
import type { UseClipboardItemsReturn } from '.';
|
||||
import { useClipboardItems } from '.';
|
||||
|
||||
function makeItems(label: string): ClipboardItems {
|
||||
// jsdom may lack ClipboardItem; a tagged plain object is enough for assertions.
|
||||
return [{ types: [label] } as unknown as ClipboardItem];
|
||||
}
|
||||
|
||||
function stubClipboard(readItems: ClipboardItems = makeItems('read')) {
|
||||
const write = vi.fn(async () => {});
|
||||
const read = vi.fn(async () => readItems);
|
||||
const navigator = {
|
||||
clipboard: { write, read },
|
||||
} as unknown as Navigator;
|
||||
return { navigator, write, read };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe(useClipboardItems, () => {
|
||||
it('reports support when the clipboard API exists', () => {
|
||||
const { navigator } = stubClipboard();
|
||||
const scope = effectScope();
|
||||
let clip: UseClipboardItemsReturn<false>;
|
||||
scope.run(() => {
|
||||
clip = useClipboardItems({ navigator });
|
||||
});
|
||||
expect(clip!.isSupported.value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('is not supported without the clipboard API', () => {
|
||||
const navigator = {} as unknown as Navigator;
|
||||
const scope = effectScope();
|
||||
let clip: UseClipboardItemsReturn<false>;
|
||||
scope.run(() => {
|
||||
clip = useClipboardItems({ navigator });
|
||||
});
|
||||
expect(clip!.isSupported.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('is not supported when navigator is undefined (SSR)', () => {
|
||||
const scope = effectScope();
|
||||
let clip: UseClipboardItemsReturn<false>;
|
||||
scope.run(() => {
|
||||
clip = useClipboardItems({ navigator: undefined });
|
||||
});
|
||||
expect(clip!.isSupported.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('copies items and sets copied flag', async () => {
|
||||
const { navigator, write } = stubClipboard();
|
||||
const items = makeItems('copy');
|
||||
const scope = effectScope();
|
||||
let clip: UseClipboardItemsReturn<false>;
|
||||
scope.run(() => {
|
||||
clip = useClipboardItems({ navigator });
|
||||
});
|
||||
|
||||
await clip!.copy(items);
|
||||
expect(write).toHaveBeenCalledWith(items);
|
||||
expect(clip!.content.value).toBe(items);
|
||||
expect(clip!.copied.value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('copies the configured source when called without args', async () => {
|
||||
const { navigator, write } = stubClipboard();
|
||||
const items = makeItems('source');
|
||||
const scope = effectScope();
|
||||
let clip: any;
|
||||
scope.run(() => {
|
||||
clip = useClipboardItems({ navigator, source: items });
|
||||
});
|
||||
|
||||
await clip.copy();
|
||||
expect(write).toHaveBeenCalledWith(items);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('copies a value resolved from an async getter', async () => {
|
||||
const { navigator, write } = stubClipboard();
|
||||
const items = makeItems('lazy');
|
||||
const scope = effectScope();
|
||||
let clip: UseClipboardItemsReturn<false>;
|
||||
scope.run(() => {
|
||||
clip = useClipboardItems({ navigator });
|
||||
});
|
||||
|
||||
await clip!.copy(async () => items);
|
||||
expect(write).toHaveBeenCalledWith(items);
|
||||
expect(clip!.content.value).toBe(items);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('skips when an async getter resolves to undefined', async () => {
|
||||
const { navigator, write } = stubClipboard();
|
||||
const scope = effectScope();
|
||||
let clip: UseClipboardItemsReturn<false>;
|
||||
scope.run(() => {
|
||||
clip = useClipboardItems({ navigator });
|
||||
});
|
||||
|
||||
await clip!.copy(async () => undefined);
|
||||
expect(write).not.toHaveBeenCalled();
|
||||
expect(clip!.copied.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('exposes copyPending around an in-flight async copy', async () => {
|
||||
const { navigator } = stubClipboard();
|
||||
const items = makeItems('done');
|
||||
const scope = effectScope();
|
||||
let clip: UseClipboardItemsReturn<false>;
|
||||
scope.run(() => {
|
||||
clip = useClipboardItems({ navigator });
|
||||
});
|
||||
|
||||
let release: (v: ClipboardItems) => void = () => {};
|
||||
const promise = clip!.copy(() => new Promise<ClipboardItems>((resolve) => {
|
||||
release = resolve;
|
||||
}));
|
||||
expect(clip!.copyPending.value).toBeTruthy();
|
||||
release(items);
|
||||
await promise;
|
||||
expect(clip!.copyPending.value).toBeFalsy();
|
||||
expect(clip!.content.value).toBe(items);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('ignores a stale async copy superseded by a newer one', async () => {
|
||||
const { navigator, write } = stubClipboard();
|
||||
const fastItems = makeItems('fast');
|
||||
const slowItems = makeItems('slow');
|
||||
const scope = effectScope();
|
||||
let clip: UseClipboardItemsReturn<false>;
|
||||
scope.run(() => {
|
||||
clip = useClipboardItems({ navigator });
|
||||
});
|
||||
|
||||
let releaseSlow: (v: ClipboardItems) => void = () => {};
|
||||
const slow = clip!.copy(() => new Promise<ClipboardItems>((resolve) => {
|
||||
releaseSlow = resolve;
|
||||
}));
|
||||
const fast = clip!.copy(async () => fastItems);
|
||||
await fast;
|
||||
releaseSlow(slowItems);
|
||||
await slow;
|
||||
|
||||
expect(clip!.content.value).toBe(fastItems);
|
||||
expect(write).toHaveBeenCalledTimes(1);
|
||||
expect(write).toHaveBeenCalledWith(fastItems);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does nothing when unsupported', async () => {
|
||||
const navigator = {} as unknown as Navigator;
|
||||
const scope = effectScope();
|
||||
let clip: UseClipboardItemsReturn<false>;
|
||||
scope.run(() => {
|
||||
clip = useClipboardItems({ navigator });
|
||||
});
|
||||
|
||||
await clip!.copy(makeItems('x'));
|
||||
expect(clip!.copied.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reads the clipboard via read()', async () => {
|
||||
const readItems = makeItems('from-clipboard');
|
||||
const { navigator, read } = stubClipboard(readItems);
|
||||
const scope = effectScope();
|
||||
let clip: UseClipboardItemsReturn<false>;
|
||||
scope.run(() => {
|
||||
clip = useClipboardItems({ navigator });
|
||||
});
|
||||
|
||||
await clip!.read();
|
||||
expect(read).toHaveBeenCalled();
|
||||
expect(clip!.content.value).toBe(readItems);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('syncs content on copy/cut events when read is enabled', async () => {
|
||||
const readItems = makeItems('synced');
|
||||
const { navigator, read } = stubClipboard(readItems);
|
||||
const scope = effectScope();
|
||||
let clip: UseClipboardItemsReturn<false>;
|
||||
scope.run(() => {
|
||||
clip = useClipboardItems({ navigator, read: true });
|
||||
});
|
||||
|
||||
globalThis.dispatchEvent(new Event('copy'));
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
expect(read).toHaveBeenCalled();
|
||||
expect(clip!.content.value).toBe(readItems);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('routes a rejected write to onError instead of throwing', async () => {
|
||||
const error = new Error('denied');
|
||||
const write = vi.fn(async () => {
|
||||
throw error;
|
||||
});
|
||||
const navigator = {
|
||||
clipboard: { write, read: vi.fn() },
|
||||
} as unknown as Navigator;
|
||||
const onError = vi.fn();
|
||||
const scope = effectScope();
|
||||
let clip: UseClipboardItemsReturn<false>;
|
||||
scope.run(() => {
|
||||
clip = useClipboardItems({ navigator, onError });
|
||||
});
|
||||
|
||||
await clip!.copy(makeItems('boom'));
|
||||
expect(onError).toHaveBeenCalledWith(error);
|
||||
expect(clip!.copied.value).toBeFalsy();
|
||||
expect(clip!.copyPending.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { shallowReadonly, shallowRef, toValue } from 'vue';
|
||||
import type { MaybeRefOrGetter, Ref } from 'vue';
|
||||
import { isFunction, noop } from '@robonen/stdlib';
|
||||
import { defaultNavigator } from '@/types';
|
||||
import type { ConfigurableNavigator } from '@/types';
|
||||
import { useSupported } from '@/composables/utilities/useSupported';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
import { useTimeoutFn } from '@/composables/animation/useTimeoutFn';
|
||||
|
||||
/**
|
||||
* A value to copy: either concrete `ClipboardItems` or an (optionally async)
|
||||
* getter that resolves to them.
|
||||
*/
|
||||
export type ClipboardItemsValue
|
||||
= | ClipboardItems
|
||||
| (() => Promise<ClipboardItems | undefined> | ClipboardItems | undefined);
|
||||
|
||||
export interface UseClipboardItemsOptions<Source> extends ConfigurableNavigator {
|
||||
/**
|
||||
* Sync `content` with the system clipboard by listening to copy/cut events
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
read?: boolean;
|
||||
|
||||
/**
|
||||
* Default source value to copy when `copy()` is called without an argument
|
||||
*/
|
||||
source?: Source;
|
||||
|
||||
/**
|
||||
* Milliseconds the `copied` flag stays `true` after a copy
|
||||
*
|
||||
* @default 1500
|
||||
*/
|
||||
copiedDuring?: number;
|
||||
|
||||
/**
|
||||
* Called when a read/write rejects, instead of throwing
|
||||
*
|
||||
* @default noop
|
||||
*/
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export interface UseClipboardItemsReturn<Optional extends boolean> {
|
||||
/**
|
||||
* Whether the async Clipboard API (with `ClipboardItem`) is available
|
||||
*/
|
||||
isSupported: Readonly<Ref<boolean>>;
|
||||
|
||||
/**
|
||||
* The current clipboard items (kept in sync when `read` is enabled)
|
||||
*/
|
||||
content: Readonly<Ref<ClipboardItems>>;
|
||||
|
||||
/**
|
||||
* `true` for `copiedDuring` ms after a successful copy
|
||||
*/
|
||||
copied: Readonly<Ref<boolean>>;
|
||||
|
||||
/**
|
||||
* `true` while an async `copy()` is in flight
|
||||
*/
|
||||
copyPending: Readonly<Ref<boolean>>;
|
||||
|
||||
/**
|
||||
* Copy clipboard items to the system clipboard
|
||||
*/
|
||||
copy: Optional extends true
|
||||
? (content?: ClipboardItemsValue) => Promise<void>
|
||||
: (content: ClipboardItemsValue) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Manually read the system clipboard into `content`
|
||||
*/
|
||||
read: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useClipboardItems
|
||||
* @category Browser
|
||||
* @description Reactive async Clipboard API with rich `ClipboardItem` support
|
||||
* (read/write images, HTML, and arbitrary MIME types — not just text).
|
||||
* SSR-safe; uses passive `copy`/`cut` listeners and guards stale async writes.
|
||||
*
|
||||
* @param {UseClipboardItemsOptions} [options={}] Options
|
||||
* @returns {UseClipboardItemsReturn} `isSupported`, `content`, `copied`, `copyPending`, `copy`, and `read`
|
||||
*
|
||||
* @example
|
||||
* const { content, copy, copied, isSupported } = useClipboardItems();
|
||||
* copy([new ClipboardItem({ 'text/plain': new Blob(['hello'], { type: 'text/plain' }) })]);
|
||||
*
|
||||
* @example
|
||||
* // Copy a lazily/asynchronously resolved value, kept in sync with the system clipboard
|
||||
* const { content } = useClipboardItems({ read: true });
|
||||
* copy(async () => buildClipboardItems());
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useClipboardItems(options?: UseClipboardItemsOptions<undefined>): UseClipboardItemsReturn<false>;
|
||||
export function useClipboardItems(options: UseClipboardItemsOptions<MaybeRefOrGetter<ClipboardItems>>): UseClipboardItemsReturn<true>;
|
||||
export function useClipboardItems(
|
||||
options: UseClipboardItemsOptions<MaybeRefOrGetter<ClipboardItems> | undefined> = {},
|
||||
): UseClipboardItemsReturn<boolean> {
|
||||
const {
|
||||
navigator = defaultNavigator,
|
||||
read = false,
|
||||
source,
|
||||
copiedDuring = 1500,
|
||||
onError = noop,
|
||||
} = options;
|
||||
|
||||
const isSupported = useSupported(() => navigator && 'clipboard' in navigator);
|
||||
|
||||
const content = shallowRef<ClipboardItems>([]);
|
||||
const copied = shallowRef(false);
|
||||
const copyPending = shallowRef(false);
|
||||
|
||||
// Guards against a slow async copy clobbering the result of a newer one
|
||||
let lastResolveId = 0;
|
||||
|
||||
const timeout = useTimeoutFn(() => {
|
||||
copied.value = false;
|
||||
}, copiedDuring, { immediate: false });
|
||||
|
||||
async function updateContent(): Promise<void> {
|
||||
if (!isSupported.value)
|
||||
return;
|
||||
|
||||
try {
|
||||
content.value = await navigator!.clipboard.read();
|
||||
}
|
||||
catch (error) {
|
||||
onError(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (isSupported.value && read)
|
||||
useEventListener(['copy', 'cut'], updateContent, { passive: true });
|
||||
|
||||
async function copy(value: ClipboardItemsValue | undefined = toValue(source)): Promise<void> {
|
||||
if (!isSupported.value || value === null || value === undefined)
|
||||
return;
|
||||
|
||||
copyPending.value = true;
|
||||
|
||||
try {
|
||||
let resolved: ClipboardItems | undefined;
|
||||
|
||||
if (isFunction(value)) {
|
||||
const currentId = ++lastResolveId;
|
||||
resolved = await value();
|
||||
|
||||
// Drop a stale async resolution superseded by a newer copy
|
||||
if (resolved === null || resolved === undefined || currentId !== lastResolveId)
|
||||
return;
|
||||
}
|
||||
else {
|
||||
resolved = value;
|
||||
}
|
||||
|
||||
await navigator!.clipboard.write(resolved);
|
||||
|
||||
content.value = resolved;
|
||||
copied.value = true;
|
||||
timeout.start();
|
||||
}
|
||||
catch (error) {
|
||||
onError(error);
|
||||
}
|
||||
finally {
|
||||
copyPending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
content: shallowReadonly(content),
|
||||
copied: shallowReadonly(copied),
|
||||
copyPending: shallowReadonly(copyPending),
|
||||
copy,
|
||||
read: updateContent,
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { noop } from '@robonen/stdlib';
|
||||
import type { Ref } from 'vue';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { useSupported } from '@/composables/browser/useSupported';
|
||||
import { useSupported } from '@/composables/utilities/useSupported';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
|
||||
|
||||
@@ -186,12 +186,12 @@ export function useColorMode<T extends string = BasicColorMode>(
|
||||
let attributeToChange: { key: string; value: string } | null = null;
|
||||
|
||||
if (attr === 'class') {
|
||||
const next = value.split(/\s/g);
|
||||
const next = new Set(value.split(/\s/g));
|
||||
|
||||
// Toggle only the classes this composable owns (derived from `modes`),
|
||||
// so unrelated classes on the element are left untouched.
|
||||
for (const owned of Object.values<string>(modes).flatMap(mode => (mode || '').split(/\s/g)).filter(Boolean)) {
|
||||
if (next.includes(owned))
|
||||
if (next.has(owned))
|
||||
classesToAdd.add(owned);
|
||||
else
|
||||
classesToRemove.add(owned);
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick, ref } from 'vue';
|
||||
import { useCssVar } from '.';
|
||||
|
||||
let mutationInstances: Array<{ cb: MutationCallback; observe: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn> }> = [];
|
||||
|
||||
class StubMutationObserver {
|
||||
observe = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
takeRecords = vi.fn(() => []);
|
||||
cb: MutationCallback;
|
||||
constructor(cb: MutationCallback) {
|
||||
this.cb = cb;
|
||||
mutationInstances.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
describe(useCssVar, () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
mutationInstances = [];
|
||||
});
|
||||
|
||||
it('reads the existing custom property from the target', () => {
|
||||
const el = document.createElement('div');
|
||||
el.style.setProperty('--color', 'red');
|
||||
document.body.appendChild(el);
|
||||
|
||||
const scope = effectScope();
|
||||
let color: ReturnType<typeof useCssVar>;
|
||||
scope.run(() => {
|
||||
color = useCssVar('--color', el);
|
||||
});
|
||||
|
||||
expect(color!.value).toBe('red');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('writes the property to the element when set', async () => {
|
||||
const el = document.createElement('div');
|
||||
document.body.appendChild(el);
|
||||
|
||||
const scope = effectScope();
|
||||
let color: ReturnType<typeof useCssVar>;
|
||||
scope.run(() => {
|
||||
color = useCssVar('--color', el);
|
||||
});
|
||||
|
||||
color!.value = 'blue';
|
||||
await nextTick();
|
||||
|
||||
expect(el.style.getPropertyValue('--color')).toBe('blue');
|
||||
expect(color!.value).toBe('blue');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('removes the property when set to null', () => {
|
||||
const el = document.createElement('div');
|
||||
el.style.setProperty('--color', 'green');
|
||||
document.body.appendChild(el);
|
||||
|
||||
const scope = effectScope();
|
||||
let color: ReturnType<typeof useCssVar>;
|
||||
scope.run(() => {
|
||||
color = useCssVar('--color', el);
|
||||
});
|
||||
|
||||
color!.value = null;
|
||||
|
||||
expect(el.style.getPropertyValue('--color')).toBe('');
|
||||
expect(color!.value).toBeNull();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('falls back to initialValue when the property is unset', () => {
|
||||
const el = document.createElement('div');
|
||||
document.body.appendChild(el);
|
||||
|
||||
const scope = effectScope();
|
||||
let color: ReturnType<typeof useCssVar>;
|
||||
scope.run(() => {
|
||||
color = useCssVar('--missing', el, { initialValue: 'gray' });
|
||||
});
|
||||
|
||||
expect(color!.value).toBe('gray');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('defaults the target to document.documentElement', () => {
|
||||
document.documentElement.style.setProperty('--root-var', 'rootval');
|
||||
|
||||
const scope = effectScope();
|
||||
let v: ReturnType<typeof useCssVar>;
|
||||
scope.run(() => {
|
||||
v = useCssVar('--root-var');
|
||||
});
|
||||
|
||||
expect(v!.value).toBe('rootval');
|
||||
v!.value = 'changed';
|
||||
expect(document.documentElement.style.getPropertyValue('--root-var')).toBe('changed');
|
||||
|
||||
document.documentElement.style.removeProperty('--root-var');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('removes the old property and re-reads when the prop name changes', async () => {
|
||||
const el = document.createElement('div');
|
||||
el.style.setProperty('--a', 'one');
|
||||
el.style.setProperty('--b', 'two');
|
||||
document.body.appendChild(el);
|
||||
|
||||
const prop = ref('--a');
|
||||
const scope = effectScope();
|
||||
let v: ReturnType<typeof useCssVar>;
|
||||
scope.run(() => {
|
||||
v = useCssVar(prop, el);
|
||||
});
|
||||
|
||||
expect(v!.value).toBe('one');
|
||||
|
||||
prop.value = '--b';
|
||||
await nextTick();
|
||||
|
||||
expect(el.style.getPropertyValue('--a')).toBe('');
|
||||
expect(v!.value).toBe('two');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('re-reads when a reactive target changes', async () => {
|
||||
const a = document.createElement('div');
|
||||
a.style.setProperty('--c', 'fromA');
|
||||
const b = document.createElement('div');
|
||||
b.style.setProperty('--c', 'fromB');
|
||||
document.body.append(a, b);
|
||||
|
||||
const target = ref<HTMLElement>(a);
|
||||
const scope = effectScope();
|
||||
let v: ReturnType<typeof useCssVar>;
|
||||
scope.run(() => {
|
||||
v = useCssVar('--c', target);
|
||||
});
|
||||
|
||||
expect(v!.value).toBe('fromA');
|
||||
|
||||
target.value = b;
|
||||
await nextTick();
|
||||
|
||||
expect(v!.value).toBe('fromB');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('attaches a MutationObserver only when observe is true', () => {
|
||||
vi.stubGlobal('MutationObserver', StubMutationObserver);
|
||||
const el = document.createElement('div');
|
||||
document.body.appendChild(el);
|
||||
|
||||
const scopeOff = effectScope();
|
||||
scopeOff.run(() => useCssVar('--x', el));
|
||||
expect(mutationInstances).toHaveLength(0);
|
||||
scopeOff.stop();
|
||||
|
||||
const scopeOn = effectScope();
|
||||
scopeOn.run(() => useCssVar('--x', el, { observe: true }));
|
||||
expect(mutationInstances).toHaveLength(1);
|
||||
expect(mutationInstances[0]!.observe).toHaveBeenCalledWith(
|
||||
el,
|
||||
expect.objectContaining({ attributeFilter: ['style', 'class'] }),
|
||||
);
|
||||
scopeOn.stop();
|
||||
});
|
||||
|
||||
it('updates the ref when an observed mutation fires', () => {
|
||||
vi.stubGlobal('MutationObserver', StubMutationObserver);
|
||||
const el = document.createElement('div');
|
||||
el.style.setProperty('--y', 'initial');
|
||||
document.body.appendChild(el);
|
||||
|
||||
const scope = effectScope();
|
||||
let v: ReturnType<typeof useCssVar>;
|
||||
scope.run(() => {
|
||||
v = useCssVar('--y', el, { observe: true });
|
||||
});
|
||||
|
||||
expect(v!.value).toBe('initial');
|
||||
|
||||
// Simulate an external change followed by a mutation record.
|
||||
el.style.setProperty('--y', 'external');
|
||||
mutationInstances[0]!.cb([{ type: 'attributes' } as MutationRecord], mutationInstances[0] as unknown as MutationObserver);
|
||||
|
||||
expect(v!.value).toBe('external');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not throw and keeps initialValue under SSR (no window)', () => {
|
||||
const scope = effectScope();
|
||||
let v: ReturnType<typeof useCssVar>;
|
||||
scope.run(() => {
|
||||
v = useCssVar('--ssr', null, {
|
||||
initialValue: 'fallback',
|
||||
window: undefined as unknown as Window,
|
||||
});
|
||||
});
|
||||
|
||||
expect(v!.value).toBe('fallback');
|
||||
// Writing is a no-op on the (missing) element but still updates the store.
|
||||
v!.value = 'next';
|
||||
expect(v!.value).toBe('next');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('uses a custom window from options for getComputedStyle', () => {
|
||||
const el = document.createElement('div');
|
||||
document.body.appendChild(el);
|
||||
|
||||
const getComputedStyle = vi.fn(() => ({
|
||||
getPropertyValue: () => ' spaced ',
|
||||
})) as unknown as Window['getComputedStyle'];
|
||||
|
||||
const fakeWindow = {
|
||||
getComputedStyle,
|
||||
document: { documentElement: el },
|
||||
} as unknown as Window;
|
||||
|
||||
const scope = effectScope();
|
||||
let v: ReturnType<typeof useCssVar>;
|
||||
scope.run(() => {
|
||||
v = useCssVar('--z', el, { window: fakeWindow });
|
||||
});
|
||||
|
||||
expect(getComputedStyle).toHaveBeenCalledWith(el);
|
||||
expect(v!.value).toBe('spaced');
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { computed, shallowRef, toValue, watch } from 'vue';
|
||||
import type { ComputedRef, MaybeRefOrGetter, WritableComputedRef } from 'vue';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
||||
import { unrefElement } from '@/composables/component/unrefElement';
|
||||
import { useMutationObserver } from '@/composables/elements/useMutationObserver';
|
||||
|
||||
export interface UseCssVarOptions extends ConfigurableWindow {
|
||||
/**
|
||||
* Value used before the variable resolves (and the fallback when the
|
||||
* computed value is empty).
|
||||
*/
|
||||
initialValue?: string;
|
||||
/**
|
||||
* Watch the target with a `MutationObserver` (filtered to `style`/`class`)
|
||||
* so the ref reflects external changes to the variable.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
observe?: boolean;
|
||||
}
|
||||
|
||||
export interface UseCssVarReturn extends WritableComputedRef<string | null | undefined> {}
|
||||
|
||||
/**
|
||||
* @name useCssVar
|
||||
* @category Browser
|
||||
* @description Read and write a CSS custom property on an element as a reactive ref.
|
||||
* Defaults to `document.documentElement`. Set `observe` to react to external
|
||||
* changes via a `MutationObserver`.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<string | null | undefined>} prop The CSS variable name (e.g. `--color`)
|
||||
* @param {MaybeComputedElementRef} [target] Element to read/write the variable on (defaults to `documentElement`)
|
||||
* @param {UseCssVarOptions} [options={}] `initialValue`, `observe`, and a configurable `window`
|
||||
* @returns {UseCssVarReturn} A writable ref; reading returns the current value, writing updates the property
|
||||
*
|
||||
* @example
|
||||
* const color = useCssVar('--color', el);
|
||||
* color.value = 'red';
|
||||
*
|
||||
* @example
|
||||
* const theme = useCssVar('--theme', null, { initialValue: 'light', observe: true });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useCssVar(
|
||||
prop: MaybeRefOrGetter<string | null | undefined>,
|
||||
target?: MaybeComputedElementRef,
|
||||
options: UseCssVarOptions = {},
|
||||
): UseCssVarReturn {
|
||||
const { window = defaultWindow, initialValue, observe = false } = options;
|
||||
|
||||
// Backing store: only mutated on explicit reads / observed changes,
|
||||
// so consumers reading `.value` never pay for `getComputedStyle`.
|
||||
const store = shallowRef<string | null | undefined>(initialValue);
|
||||
|
||||
const elRef: ComputedRef<HTMLElement | SVGElement | undefined> = computed(
|
||||
() => (unrefElement(target) as HTMLElement | SVGElement | undefined) ?? window?.document?.documentElement,
|
||||
);
|
||||
|
||||
const read = (): void => {
|
||||
const el = elRef.value;
|
||||
const key = toValue(prop);
|
||||
|
||||
if (!el || !window || !key)
|
||||
return;
|
||||
|
||||
const value = window.getComputedStyle(el).getPropertyValue(key)?.trim();
|
||||
store.value = value || store.value || initialValue;
|
||||
};
|
||||
|
||||
const write = (value: string | null | undefined): void => {
|
||||
const el = elRef.value;
|
||||
const key = toValue(prop);
|
||||
|
||||
store.value = value;
|
||||
|
||||
if (!el?.style || !key)
|
||||
return;
|
||||
|
||||
if (value === null || value === undefined)
|
||||
el.style.removeProperty(key);
|
||||
else
|
||||
el.style.setProperty(key, value);
|
||||
};
|
||||
|
||||
if (observe) {
|
||||
useMutationObserver(elRef, read, {
|
||||
attributeFilter: ['style', 'class'],
|
||||
window,
|
||||
});
|
||||
}
|
||||
|
||||
// Single watcher: when the element or prop changes, drop the old custom
|
||||
// property and re-read the current value.
|
||||
watch(
|
||||
() => [elRef.value, toValue(prop)] as const,
|
||||
([el, key], old) => {
|
||||
const [oldEl, oldKey] = old ?? [];
|
||||
if (oldEl?.style && oldKey && (oldEl !== el || oldKey !== key))
|
||||
oldEl.style.removeProperty(oldKey);
|
||||
read();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
return computed<string | null | undefined>({
|
||||
get: () => store.value,
|
||||
set: write,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick } from 'vue';
|
||||
import { useDark } from '.';
|
||||
|
||||
type Listener = (event: { matches: boolean }) => void;
|
||||
|
||||
interface StubMql {
|
||||
readonly matches: boolean;
|
||||
media: string;
|
||||
addEventListener: (type: string, cb: Listener) => void;
|
||||
removeEventListener: (type: string, cb: Listener) => void;
|
||||
dispatch: (value: boolean) => void;
|
||||
}
|
||||
|
||||
function makeMql(initialMatches: boolean, media = ''): StubMql {
|
||||
const listeners = new Set<Listener>();
|
||||
let matches = initialMatches;
|
||||
|
||||
return {
|
||||
get matches() {
|
||||
return matches;
|
||||
},
|
||||
media,
|
||||
addEventListener: (_: string, cb: Listener) => listeners.add(cb),
|
||||
removeEventListener: (_: string, cb: Listener) => listeners.delete(cb),
|
||||
dispatch(value: boolean) {
|
||||
matches = value;
|
||||
for (const cb of listeners) cb({ matches: value });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stub `window` that reuses the real jsdom `document` (so DOM updates
|
||||
* applied to `<html>` are observable) but with a controllable `matchMedia` for
|
||||
* `prefers-color-scheme: dark`, an isolated in-memory `localStorage`, and a
|
||||
* `getComputedStyle` shim for the transition-disabling reflow.
|
||||
*/
|
||||
function makeWindow(prefersDark: StubMql) {
|
||||
const map = new Map<string, string>();
|
||||
|
||||
const storage: Storage = {
|
||||
getItem: (key: string) => (map.has(key) ? map.get(key)! : null),
|
||||
setItem: (key: string, value: string) => { map.set(key, String(value)); },
|
||||
removeItem: (key: string) => { map.delete(key); },
|
||||
clear: () => map.clear(),
|
||||
key: (index: number) => [...map.keys()][index] ?? null,
|
||||
get length() {
|
||||
return map.size;
|
||||
},
|
||||
};
|
||||
|
||||
const win = {
|
||||
document: globalThis.document,
|
||||
matchMedia: vi.fn((query: string) =>
|
||||
query.includes('dark') ? prefersDark : makeMql(false, query)),
|
||||
localStorage: storage,
|
||||
getComputedStyle: () => ({ opacity: '1' }),
|
||||
dispatchEvent: () => true,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
} as unknown as Window & typeof globalThis;
|
||||
|
||||
return { win, storage, map };
|
||||
}
|
||||
|
||||
function reset() {
|
||||
document.documentElement.className = '';
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
|
||||
describe(useDark, () => {
|
||||
beforeEach(() => {
|
||||
reset();
|
||||
// Ensure module-captured defaultWindow.matchMedia is undefined so the
|
||||
// composable must use the injected window.
|
||||
vi.stubGlobal('matchMedia', undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
reset();
|
||||
});
|
||||
|
||||
it('reflects the system preference in auto mode (dark)', async () => {
|
||||
const prefersDark = makeMql(true);
|
||||
const { win } = makeWindow(prefersDark);
|
||||
const scope = effectScope();
|
||||
let isDark: ReturnType<typeof useDark>;
|
||||
|
||||
scope.run(() => {
|
||||
isDark = useDark({ window: win });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(isDark!.value).toBeTruthy();
|
||||
expect(document.documentElement.classList.contains('dark')).toBeTruthy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reflects the system preference in auto mode (light)', async () => {
|
||||
const prefersDark = makeMql(false);
|
||||
const { win } = makeWindow(prefersDark);
|
||||
const scope = effectScope();
|
||||
let isDark: ReturnType<typeof useDark>;
|
||||
|
||||
scope.run(() => {
|
||||
isDark = useDark({ window: win });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(isDark!.value).toBeFalsy();
|
||||
// Default valueLight is '' so no light class is applied.
|
||||
expect(document.documentElement.classList.contains('dark')).toBeFalsy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('writing true while the system prefers light applies the dark class', async () => {
|
||||
const prefersDark = makeMql(false);
|
||||
const { win } = makeWindow(prefersDark);
|
||||
const scope = effectScope();
|
||||
let isDark: ReturnType<typeof useDark>;
|
||||
|
||||
scope.run(() => {
|
||||
isDark = useDark({ window: win });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(isDark!.value).toBeFalsy();
|
||||
|
||||
isDark!.value = true;
|
||||
await nextTick();
|
||||
|
||||
expect(isDark!.value).toBeTruthy();
|
||||
expect(document.documentElement.classList.contains('dark')).toBeTruthy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('writing true while the system prefers dark falls back to auto', async () => {
|
||||
const prefersDark = makeMql(true);
|
||||
const { win, storage } = makeWindow(prefersDark);
|
||||
const scope = effectScope();
|
||||
let isDark: ReturnType<typeof useDark>;
|
||||
|
||||
scope.run(() => {
|
||||
// Start from an explicit light value so the store is not already auto.
|
||||
isDark = useDark({ window: win });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
isDark!.value = false;
|
||||
await nextTick();
|
||||
expect(storage.getItem('vuetools-color-scheme')).toBe('light');
|
||||
|
||||
// System prefers dark, so requesting dark should resolve to 'auto'.
|
||||
isDark!.value = true;
|
||||
await nextTick();
|
||||
|
||||
expect(isDark!.value).toBeTruthy();
|
||||
expect(storage.getItem('vuetools-color-scheme')).toBe('auto');
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('writing false while the system prefers dark falls back to auto', async () => {
|
||||
const prefersDark = makeMql(false);
|
||||
const { win, storage } = makeWindow(prefersDark);
|
||||
const scope = effectScope();
|
||||
let isDark: ReturnType<typeof useDark>;
|
||||
|
||||
scope.run(() => {
|
||||
isDark = useDark({ window: win });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
// System prefers light, so requesting light resolves to 'auto'.
|
||||
isDark!.value = false;
|
||||
await nextTick();
|
||||
|
||||
expect(isDark!.value).toBeFalsy();
|
||||
expect(storage.getItem('vuetools-color-scheme')).toBe('auto');
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reacts to system preference changes while in auto mode', async () => {
|
||||
const prefersDark = makeMql(false);
|
||||
const { win } = makeWindow(prefersDark);
|
||||
const scope = effectScope();
|
||||
let isDark: ReturnType<typeof useDark>;
|
||||
|
||||
scope.run(() => {
|
||||
isDark = useDark({ window: win });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(isDark!.value).toBeFalsy();
|
||||
|
||||
prefersDark.dispatch(true);
|
||||
await nextTick();
|
||||
|
||||
expect(isDark!.value).toBeTruthy();
|
||||
expect(document.documentElement.classList.contains('dark')).toBeTruthy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('honours custom valueDark / valueLight on a custom attribute', async () => {
|
||||
const prefersDark = makeMql(false);
|
||||
const { win } = makeWindow(prefersDark);
|
||||
const scope = effectScope();
|
||||
let isDark: ReturnType<typeof useDark>;
|
||||
|
||||
scope.run(() => {
|
||||
isDark = useDark({
|
||||
window: win,
|
||||
attribute: 'data-theme',
|
||||
valueDark: 'night',
|
||||
valueLight: 'day',
|
||||
});
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('day');
|
||||
|
||||
isDark!.value = true;
|
||||
await nextTick();
|
||||
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('night');
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('persists to a custom storageKey', async () => {
|
||||
const prefersDark = makeMql(false);
|
||||
const { win, storage } = makeWindow(prefersDark);
|
||||
const scope = effectScope();
|
||||
let isDark: ReturnType<typeof useDark>;
|
||||
|
||||
scope.run(() => {
|
||||
isDark = useDark({ window: win, storageKey: 'my-dark' });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
isDark!.value = true;
|
||||
await nextTick();
|
||||
|
||||
expect(storage.getItem('my-dark')).toBe('dark');
|
||||
expect(storage.getItem('vuetools-color-scheme')).toBeNull();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('uses a custom storage backend', async () => {
|
||||
const prefersDark = makeMql(false);
|
||||
const { win } = makeWindow(prefersDark);
|
||||
const map = new Map<string, string>();
|
||||
const storage: Storage = {
|
||||
getItem: (key: string) => (map.has(key) ? map.get(key)! : null),
|
||||
setItem: (key: string, value: string) => { map.set(key, String(value)); },
|
||||
removeItem: (key: string) => { map.delete(key); },
|
||||
clear: () => map.clear(),
|
||||
key: (index: number) => [...map.keys()][index] ?? null,
|
||||
get length() {
|
||||
return map.size;
|
||||
},
|
||||
};
|
||||
const scope = effectScope();
|
||||
let isDark: ReturnType<typeof useDark>;
|
||||
|
||||
scope.run(() => {
|
||||
isDark = useDark({ window: win, storage });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
isDark!.value = true;
|
||||
await nextTick();
|
||||
|
||||
expect(map.get('vuetools-color-scheme')).toBe('dark');
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('invokes a custom onChanged handler with the boolean state', async () => {
|
||||
const prefersDark = makeMql(true);
|
||||
const { win } = makeWindow(prefersDark);
|
||||
const onChanged = vi.fn();
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
useDark({ window: win, onChanged });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(onChanged).toHaveBeenCalled();
|
||||
const [boolValue, handler, mode] = onChanged.mock.calls[0]!;
|
||||
expect(boolValue).toBeTruthy();
|
||||
expect(typeof handler).toBe('function');
|
||||
expect(mode).toBe('dark');
|
||||
// Default handler suppressed: no class applied.
|
||||
expect(document.documentElement.classList.contains('dark')).toBeFalsy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not throw on the SSR/unsupported path (no window)', async () => {
|
||||
const scope = effectScope();
|
||||
let isDark: ReturnType<typeof useDark>;
|
||||
|
||||
expect(() => {
|
||||
scope.run(() => {
|
||||
isDark = useDark({ window: undefined });
|
||||
});
|
||||
}).not.toThrow();
|
||||
await nextTick();
|
||||
|
||||
// System detection unavailable -> defaults to light -> isDark false.
|
||||
expect(isDark!.value).toBeFalsy();
|
||||
|
||||
// Still writable in memory.
|
||||
isDark!.value = true;
|
||||
await nextTick();
|
||||
expect(isDark!.value).toBeTruthy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { computed } from 'vue';
|
||||
import type { WritableComputedRef } from 'vue';
|
||||
import { useColorMode } from '@/composables/browser/useColorMode';
|
||||
import type { BasicColorSchema, UseColorModeOptions } from '@/composables/browser/useColorMode';
|
||||
|
||||
export interface UseDarkOptions extends Omit<UseColorModeOptions<BasicColorSchema>, 'modes' | 'onChanged'> {
|
||||
/**
|
||||
* Value applied to the target element when `isDark` is `true`.
|
||||
*
|
||||
* @default 'dark'
|
||||
*/
|
||||
valueDark?: string;
|
||||
|
||||
/**
|
||||
* Value applied to the target element when `isDark` is `false`.
|
||||
*
|
||||
* @default ''
|
||||
*/
|
||||
valueLight?: string;
|
||||
|
||||
/**
|
||||
* Custom handler called whenever the resolved mode changes. When specified,
|
||||
* the default DOM update is overridden (call `defaultHandler` to keep it).
|
||||
*
|
||||
* @default undefined
|
||||
*/
|
||||
onChanged?: (isDark: boolean, defaultHandler: (mode: BasicColorSchema) => void, mode: BasicColorSchema) => void;
|
||||
}
|
||||
|
||||
export type UseDarkReturn = WritableComputedRef<boolean>;
|
||||
|
||||
/**
|
||||
* @name useDark
|
||||
* @category Browser
|
||||
* @description Reactive dark mode boolean with system detection and storage
|
||||
* persistence, built on `useColorMode`. Writing `false` while the system
|
||||
* already prefers light (or `true` while it prefers dark) falls back to
|
||||
* `'auto'`, so the mode keeps tracking the OS preference.
|
||||
*
|
||||
* @param {UseDarkOptions} [options={}] Options
|
||||
* @returns {UseDarkReturn} A writable boolean ref; `true` when dark mode is active
|
||||
*
|
||||
* @example
|
||||
* const isDark = useDark();
|
||||
* isDark.value = true;
|
||||
*
|
||||
* @example
|
||||
* // Toggle a data attribute instead of a class
|
||||
* const isDark = useDark({
|
||||
* attribute: 'data-theme',
|
||||
* valueDark: 'dark',
|
||||
* valueLight: 'light',
|
||||
* });
|
||||
*
|
||||
* @example
|
||||
* import { useToggle } from '@/composables/state/useToggle';
|
||||
*
|
||||
* const isDark = useDark();
|
||||
* const toggleDark = useToggle(isDark);
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useDark(options: UseDarkOptions = {}): UseDarkReturn {
|
||||
const {
|
||||
valueDark = 'dark',
|
||||
valueLight = '',
|
||||
} = options;
|
||||
|
||||
const mode = useColorMode({
|
||||
...options,
|
||||
onChanged: (resolved, defaultHandler) => {
|
||||
if (options.onChanged)
|
||||
options.onChanged(resolved === 'dark', defaultHandler, resolved);
|
||||
else
|
||||
defaultHandler(resolved);
|
||||
},
|
||||
modes: {
|
||||
dark: valueDark,
|
||||
light: valueLight,
|
||||
},
|
||||
});
|
||||
|
||||
const isDark = computed<boolean>({
|
||||
get() {
|
||||
return mode.state.value === 'dark';
|
||||
},
|
||||
set(value) {
|
||||
const next = value ? 'dark' : 'light';
|
||||
|
||||
// When the requested state already matches the system preference, fall
|
||||
// back to `'auto'` so the mode keeps following the OS going forward.
|
||||
mode.value = mode.system.value === next ? 'auto' : next;
|
||||
},
|
||||
});
|
||||
|
||||
return isDark;
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, isReadonly, nextTick } from 'vue';
|
||||
import { useDevicePixelRatio } from '.';
|
||||
|
||||
type Listener = (event: { matches: boolean }) => void;
|
||||
|
||||
interface StubMql {
|
||||
readonly matches: boolean;
|
||||
media: string;
|
||||
addEventListener: (type: string, cb: Listener) => void;
|
||||
removeEventListener: (type: string, cb: Listener) => void;
|
||||
dispatch: (value: boolean) => void;
|
||||
}
|
||||
|
||||
function makeMql(media = ''): StubMql {
|
||||
const listeners = new Set<Listener>();
|
||||
let matches = true;
|
||||
|
||||
return {
|
||||
get matches() {
|
||||
return matches;
|
||||
},
|
||||
media,
|
||||
addEventListener: (_: string, cb: Listener) => listeners.add(cb),
|
||||
removeEventListener: (_: string, cb: Listener) => listeners.delete(cb),
|
||||
dispatch(value: boolean) {
|
||||
matches = value;
|
||||
for (const cb of listeners) cb({ matches: value });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns one MediaQueryList per query string so re-binding can be exercised. */
|
||||
function stubMatchMediaByQuery() {
|
||||
const map = new Map<string, StubMql>();
|
||||
const spy = vi.fn((query: string) => {
|
||||
if (!map.has(query))
|
||||
map.set(query, makeMql(query));
|
||||
return map.get(query)!;
|
||||
});
|
||||
vi.stubGlobal('matchMedia', spy);
|
||||
return { spy, map };
|
||||
}
|
||||
|
||||
/** A minimal window stub whose `devicePixelRatio` can be mutated in tests. */
|
||||
function makeWindowStub(initialRatio: number): Window & { devicePixelRatio: number } {
|
||||
return {
|
||||
devicePixelRatio: initialRatio,
|
||||
matchMedia: globalThis.matchMedia,
|
||||
} as unknown as Window & { devicePixelRatio: number };
|
||||
}
|
||||
|
||||
describe(useDevicePixelRatio, () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('matchMedia', undefined);
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('reflects the initial devicePixelRatio', async () => {
|
||||
stubMatchMediaByQuery();
|
||||
const window = makeWindowStub(2);
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useDevicePixelRatio>;
|
||||
scope.run(() => {
|
||||
result = useDevicePixelRatio({ window });
|
||||
});
|
||||
await nextTick();
|
||||
expect(result!.pixelRatio.value).toBe(2);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('returns a readonly ref', async () => {
|
||||
stubMatchMediaByQuery();
|
||||
const window = makeWindowStub(1);
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useDevicePixelRatio>;
|
||||
scope.run(() => {
|
||||
result = useDevicePixelRatio({ window });
|
||||
});
|
||||
await nextTick();
|
||||
expect(isReadonly(result!.pixelRatio)).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('updates when the resolution media query flips', async () => {
|
||||
const { map } = stubMatchMediaByQuery();
|
||||
const window = makeWindowStub(1);
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useDevicePixelRatio>;
|
||||
scope.run(() => {
|
||||
result = useDevicePixelRatio({ window });
|
||||
});
|
||||
await nextTick();
|
||||
expect(result!.pixelRatio.value).toBe(1);
|
||||
|
||||
// Simulate a zoom: the real ratio changes, then the current query flips.
|
||||
window.devicePixelRatio = 2;
|
||||
map.get('(resolution: 1dppx)')!.dispatch(false);
|
||||
await nextTick();
|
||||
expect(result!.pixelRatio.value).toBe(2);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('re-binds the listener after the ratio changes', async () => {
|
||||
const { spy, map } = stubMatchMediaByQuery();
|
||||
const window = makeWindowStub(1);
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useDevicePixelRatio>;
|
||||
scope.run(() => {
|
||||
result = useDevicePixelRatio({ window });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
window.devicePixelRatio = 3;
|
||||
map.get('(resolution: 1dppx)')!.dispatch(false);
|
||||
await nextTick();
|
||||
expect(result!.pixelRatio.value).toBe(3);
|
||||
|
||||
// The query string should now track 3dppx (new MediaQueryList created).
|
||||
expect(spy).toHaveBeenCalledWith('(resolution: 3dppx)');
|
||||
|
||||
// Further changes are driven by the new MediaQueryList.
|
||||
window.devicePixelRatio = 1.5;
|
||||
map.get('(resolution: 3dppx)')!.dispatch(false);
|
||||
await nextTick();
|
||||
expect(result!.pixelRatio.value).toBe(1.5);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('stops tracking after stop()', async () => {
|
||||
const { map } = stubMatchMediaByQuery();
|
||||
const window = makeWindowStub(1);
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useDevicePixelRatio>;
|
||||
scope.run(() => {
|
||||
result = useDevicePixelRatio({ window });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
result!.stop();
|
||||
|
||||
window.devicePixelRatio = 2;
|
||||
map.get('(resolution: 1dppx)')!.dispatch(false);
|
||||
await nextTick();
|
||||
expect(result!.pixelRatio.value).toBe(1);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('defaults to 1 with a no-op stop when no window is available (SSR)', async () => {
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useDevicePixelRatio>;
|
||||
scope.run(() => {
|
||||
result = useDevicePixelRatio({ window: undefined });
|
||||
});
|
||||
await nextTick();
|
||||
expect(result!.pixelRatio.value).toBe(1);
|
||||
// stop() must be safe to call even when nothing was bound.
|
||||
expect(() => result!.stop()).not.toThrow();
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
import { noop } from '@robonen/stdlib';
|
||||
import { shallowReadonly, shallowRef, watch } from 'vue';
|
||||
import type { ShallowRef, WatchStopHandle } from 'vue';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { useMediaQuery } from '@/composables/browser/useMediaQuery';
|
||||
|
||||
export interface UseDevicePixelRatioOptions extends ConfigurableWindow {}
|
||||
|
||||
export interface UseDevicePixelRatioReturn {
|
||||
/**
|
||||
* Reactive, readonly `window.devicePixelRatio`. Defaults to `1` on the
|
||||
* server / when no window is available.
|
||||
*/
|
||||
pixelRatio: Readonly<ShallowRef<number>>;
|
||||
/**
|
||||
* Stop tracking the device pixel ratio. Idempotent and a no-op on SSR.
|
||||
*/
|
||||
stop: WatchStopHandle;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useDevicePixelRatio
|
||||
* @category Browser
|
||||
* @description Reactively track `window.devicePixelRatio`, updated via a
|
||||
* `matchMedia(resolution)` listener (fires on zoom and on monitor changes).
|
||||
*
|
||||
* @param {UseDevicePixelRatioOptions} [options={}] Options (custom `window`)
|
||||
* @returns {UseDevicePixelRatioReturn} `{ pixelRatio, stop }`
|
||||
*
|
||||
* @example
|
||||
* const { pixelRatio } = useDevicePixelRatio();
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useDevicePixelRatio(options: UseDevicePixelRatioOptions = {}): UseDevicePixelRatioReturn {
|
||||
const { window = defaultWindow } = options;
|
||||
|
||||
const pixelRatio = shallowRef(1);
|
||||
|
||||
// `devicePixelRatio` has no `change` event; the canonical trick is to watch a
|
||||
// `(resolution: Ndppx)` media query whose threshold tracks the current ratio.
|
||||
// When the real ratio crosses that threshold the query flips, re-evaluating
|
||||
// the reactive query string and re-binding to a fresh MediaQueryList.
|
||||
const query = useMediaQuery(() => `(resolution: ${pixelRatio.value}dppx)`, options);
|
||||
|
||||
let stop: WatchStopHandle = noop;
|
||||
|
||||
if (window) {
|
||||
stop = watch(
|
||||
query,
|
||||
() => {
|
||||
pixelRatio.value = window.devicePixelRatio;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
pixelRatio: shallowReadonly(pixelRatio),
|
||||
stop,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick } from 'vue';
|
||||
import { useDocumentPiP } from '.';
|
||||
import type { DocumentPictureInPictureOptions } from '.';
|
||||
|
||||
/**
|
||||
* Build a fake Picture-in-Picture `Window` that records its listeners and
|
||||
* `close()` call. Passed through options so it reaches the import-time-captured
|
||||
* `defaultWindow` substitute (see test gotcha).
|
||||
*/
|
||||
function createPipWindow() {
|
||||
const listeners = new Map<string, EventListener>();
|
||||
|
||||
const pip = {
|
||||
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
listeners.set(type, listener);
|
||||
}),
|
||||
close: vi.fn(),
|
||||
} as unknown as Window;
|
||||
|
||||
function firePagehide() {
|
||||
listeners.get('pagehide')?.(new Event('pagehide'));
|
||||
}
|
||||
|
||||
return { pip, firePagehide, close: pip.close as ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
function createWindow(pip: Window) {
|
||||
const requestWindow = vi.fn(async (_options?: DocumentPictureInPictureOptions) => pip);
|
||||
const win = {
|
||||
documentPictureInPicture: { window: null, requestWindow },
|
||||
} as unknown as Window & typeof globalThis;
|
||||
|
||||
return { window: win, requestWindow };
|
||||
}
|
||||
|
||||
describe(useDocumentPiP, () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('reports supported when documentPictureInPicture exists on window', () => {
|
||||
const scope = effectScope();
|
||||
const { pip } = createPipWindow();
|
||||
const { window } = createWindow(pip);
|
||||
|
||||
let result: ReturnType<typeof useDocumentPiP>;
|
||||
scope.run(() => {
|
||||
result = useDocumentPiP({ window });
|
||||
});
|
||||
|
||||
expect(result!.isSupported.value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reports unsupported when the API is absent', () => {
|
||||
const scope = effectScope();
|
||||
const win = {} as unknown as Window & typeof globalThis;
|
||||
|
||||
let result: ReturnType<typeof useDocumentPiP>;
|
||||
scope.run(() => {
|
||||
result = useDocumentPiP({ window: win });
|
||||
});
|
||||
|
||||
expect(result!.isSupported.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('is SSR safe when window is undefined', async () => {
|
||||
const scope = effectScope();
|
||||
|
||||
let result: ReturnType<typeof useDocumentPiP>;
|
||||
scope.run(() => {
|
||||
result = useDocumentPiP({ window: undefined as unknown as Window });
|
||||
});
|
||||
|
||||
expect(result!.isSupported.value).toBeFalsy();
|
||||
await expect(result!.open()).resolves.toBeUndefined();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('opens a PiP window, tracks it, and forwards options', async () => {
|
||||
const scope = effectScope();
|
||||
const { pip } = createPipWindow();
|
||||
const { window, requestWindow } = createWindow(pip);
|
||||
|
||||
let result: ReturnType<typeof useDocumentPiP>;
|
||||
scope.run(() => {
|
||||
result = useDocumentPiP({ window });
|
||||
});
|
||||
|
||||
const returned = await result!.open({ width: 320, height: 240 });
|
||||
await nextTick();
|
||||
|
||||
expect(requestWindow).toHaveBeenCalledWith({ width: 320, height: 240 });
|
||||
expect(returned).toBe(pip);
|
||||
expect(result!.pipWindow.value).toBe(pip);
|
||||
expect(result!.isOpen.value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('clears the reference when the PiP window emits pagehide', async () => {
|
||||
const scope = effectScope();
|
||||
const { pip, firePagehide } = createPipWindow();
|
||||
const { window } = createWindow(pip);
|
||||
|
||||
let result: ReturnType<typeof useDocumentPiP>;
|
||||
scope.run(() => {
|
||||
result = useDocumentPiP({ window });
|
||||
});
|
||||
|
||||
await result!.open();
|
||||
expect(result!.isOpen.value).toBeTruthy();
|
||||
|
||||
firePagehide();
|
||||
await nextTick();
|
||||
|
||||
expect(result!.pipWindow.value).toBeNull();
|
||||
expect(result!.isOpen.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('close() closes the window and clears state', async () => {
|
||||
const scope = effectScope();
|
||||
const { pip, close } = createPipWindow();
|
||||
const { window } = createWindow(pip);
|
||||
|
||||
let result: ReturnType<typeof useDocumentPiP>;
|
||||
scope.run(() => {
|
||||
result = useDocumentPiP({ window });
|
||||
});
|
||||
|
||||
await result!.open();
|
||||
result!.close();
|
||||
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(result!.pipWindow.value).toBeNull();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('closes the PiP window when the scope is disposed', async () => {
|
||||
const scope = effectScope();
|
||||
const { pip, close } = createPipWindow();
|
||||
const { window } = createWindow(pip);
|
||||
|
||||
let result: ReturnType<typeof useDocumentPiP>;
|
||||
scope.run(() => {
|
||||
result = useDocumentPiP({ window });
|
||||
});
|
||||
|
||||
await result!.open();
|
||||
scope.stop();
|
||||
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stores the error and invokes onError when open() rejects', async () => {
|
||||
const scope = effectScope();
|
||||
const error = new DOMException('gesture required', 'NotAllowedError');
|
||||
const requestWindow = vi.fn(async () => {
|
||||
throw error;
|
||||
});
|
||||
const win = {
|
||||
documentPictureInPicture: { window: null, requestWindow },
|
||||
} as unknown as Window & typeof globalThis;
|
||||
const onError = vi.fn();
|
||||
|
||||
let result: ReturnType<typeof useDocumentPiP>;
|
||||
scope.run(() => {
|
||||
result = useDocumentPiP({ window: win, onError });
|
||||
});
|
||||
|
||||
await expect(result!.open()).resolves.toBeUndefined();
|
||||
expect(onError).toHaveBeenCalledWith(error);
|
||||
expect(result!.error.value).toBe(error);
|
||||
expect(result!.isOpen.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { computed, shallowRef } from 'vue';
|
||||
import type { ComputedRef, ShallowRef } from 'vue';
|
||||
import { noop } from '@robonen/stdlib';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { useSupported } from '@/composables/utilities/useSupported';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
|
||||
export interface DocumentPictureInPictureOptions {
|
||||
/**
|
||||
* The initial width of the Picture-in-Picture window, in pixels.
|
||||
*/
|
||||
width?: number;
|
||||
|
||||
/**
|
||||
* The initial height of the Picture-in-Picture window, in pixels.
|
||||
*/
|
||||
height?: number;
|
||||
|
||||
/**
|
||||
* Hide the "back to tab" button in the Picture-in-Picture window.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
disallowReturnToOpener?: boolean;
|
||||
|
||||
/**
|
||||
* Open the window in its default position/size rather than reusing the last one.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
preferInitialWindowPlacement?: boolean;
|
||||
}
|
||||
|
||||
interface DocumentPictureInPicture {
|
||||
readonly window: Window | null;
|
||||
requestWindow: (options?: DocumentPictureInPictureOptions) => Promise<Window>;
|
||||
}
|
||||
|
||||
interface WindowWithDocumentPiP {
|
||||
documentPictureInPicture: DocumentPictureInPicture;
|
||||
}
|
||||
|
||||
export interface UseDocumentPiPOptions extends ConfigurableWindow {
|
||||
/**
|
||||
* Called when `open()` rejects (e.g. not triggered by a user gesture) instead
|
||||
* of throwing. The same value is also stored in the returned `error` ref.
|
||||
*
|
||||
* @default noop
|
||||
*/
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export interface UseDocumentPiPReturn {
|
||||
/**
|
||||
* Whether the [Document Picture-in-Picture API](https://developer.mozilla.org/en-US/docs/Web/API/DocumentPictureInPicture) is supported
|
||||
*/
|
||||
isSupported: ComputedRef<boolean>;
|
||||
|
||||
/**
|
||||
* The active Picture-in-Picture `Window`, or `null` when none is open
|
||||
*/
|
||||
pipWindow: ShallowRef<Window | null>;
|
||||
|
||||
/**
|
||||
* Whether a Picture-in-Picture window is currently open
|
||||
*/
|
||||
isOpen: ComputedRef<boolean>;
|
||||
|
||||
/**
|
||||
* The last error thrown by `open()`, or `null`
|
||||
*/
|
||||
error: ShallowRef<unknown | null>;
|
||||
|
||||
/**
|
||||
* Open a Picture-in-Picture window. Must be called from a user gesture.
|
||||
* Resolves with the new `Window`, or `undefined` when unsupported.
|
||||
*/
|
||||
open: (pipOptions?: DocumentPictureInPictureOptions) => Promise<Window | undefined>;
|
||||
|
||||
/**
|
||||
* Close the active Picture-in-Picture window, if any
|
||||
*/
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useDocumentPiP
|
||||
* @category Browser
|
||||
* @description Reactive wrapper around the [Document Picture-in-Picture API](https://developer.mozilla.org/en-US/docs/Web/API/DocumentPictureInPicture) for rendering arbitrary DOM in an always-on-top window.
|
||||
*
|
||||
* @param {UseDocumentPiPOptions} [options={}] Options
|
||||
* @param {Function} [options.onError=noop] Error callback invoked instead of throwing
|
||||
* @param {Window} [options.window=defaultWindow] Custom `window` instance
|
||||
* @returns {UseDocumentPiPReturn} `isSupported`, `pipWindow`, `isOpen`, `error`, `open()`, and `close()`
|
||||
*
|
||||
* @example
|
||||
* const { isSupported, pipWindow, open } = useDocumentPiP();
|
||||
* async function popOut(content: HTMLElement) {
|
||||
* const win = await open({ width: 320, height: 240 });
|
||||
* win?.document.body.append(content);
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
* // Move a player into the PiP window and track open state
|
||||
* const { isOpen, pipWindow, open, close } = useDocumentPiP();
|
||||
* watchEffect(() => {
|
||||
* if (pipWindow.value)
|
||||
* pipWindow.value.document.body.append(playerEl);
|
||||
* });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useDocumentPiP(options: UseDocumentPiPOptions = {}): UseDocumentPiPReturn {
|
||||
const {
|
||||
window = defaultWindow,
|
||||
onError = noop,
|
||||
} = options;
|
||||
|
||||
const isSupported = useSupported(() => !!window && 'documentPictureInPicture' in window);
|
||||
const pipWindow = shallowRef<Window | null>(null);
|
||||
const error = shallowRef<unknown | null>(null);
|
||||
|
||||
const isOpen = computed<boolean>(() => pipWindow.value !== null);
|
||||
|
||||
function handleClose(): void {
|
||||
pipWindow.value = null;
|
||||
}
|
||||
|
||||
async function open(pipOptions?: DocumentPictureInPictureOptions): Promise<Window | undefined> {
|
||||
if (!isSupported.value || !window)
|
||||
return undefined;
|
||||
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const controller = (window as unknown as WindowWithDocumentPiP).documentPictureInPicture;
|
||||
const pip = await controller.requestWindow(pipOptions);
|
||||
|
||||
// The PiP window closing (user or programmatic) clears our reference.
|
||||
pip.addEventListener('pagehide', handleClose, { once: true });
|
||||
pipWindow.value = pip;
|
||||
|
||||
return pip;
|
||||
}
|
||||
catch (err) {
|
||||
error.value = err;
|
||||
onError(err);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
pipWindow.value?.close();
|
||||
pipWindow.value = null;
|
||||
}
|
||||
|
||||
tryOnScopeDispose(close);
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
pipWindow,
|
||||
isOpen,
|
||||
error,
|
||||
open,
|
||||
close,
|
||||
};
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick } from 'vue';
|
||||
import { useDocumentReadyState } from '.';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
Object.defineProperty(document, 'readyState', { value: 'complete', configurable: true });
|
||||
});
|
||||
|
||||
function setReadyState(state: DocumentReadyState) {
|
||||
Object.defineProperty(document, 'readyState', { value: state, configurable: true });
|
||||
document.dispatchEvent(new Event('readystatechange'));
|
||||
}
|
||||
|
||||
describe(useDocumentReadyState, () => {
|
||||
it('reads the current ready state', () => {
|
||||
const scope = effectScope();
|
||||
let readyState: ReturnType<typeof useDocumentReadyState>;
|
||||
scope.run(() => {
|
||||
readyState = useDocumentReadyState();
|
||||
});
|
||||
|
||||
expect(readyState!.value).toBe('complete');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reflects a non-default initial state at setup time', () => {
|
||||
Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true });
|
||||
|
||||
const scope = effectScope();
|
||||
let readyState: ReturnType<typeof useDocumentReadyState>;
|
||||
scope.run(() => {
|
||||
readyState = useDocumentReadyState();
|
||||
});
|
||||
|
||||
expect(readyState!.value).toBe('loading');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('updates on readystatechange', async () => {
|
||||
Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true });
|
||||
|
||||
const scope = effectScope();
|
||||
let readyState: ReturnType<typeof useDocumentReadyState>;
|
||||
scope.run(() => {
|
||||
readyState = useDocumentReadyState();
|
||||
});
|
||||
|
||||
expect(readyState!.value).toBe('loading');
|
||||
|
||||
setReadyState('interactive');
|
||||
await nextTick();
|
||||
expect(readyState!.value).toBe('interactive');
|
||||
|
||||
setReadyState('complete');
|
||||
await nextTick();
|
||||
expect(readyState!.value).toBe('complete');
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('invokes onChange with new state, previous state, and the event', async () => {
|
||||
Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true });
|
||||
|
||||
const onChange = vi.fn();
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
useDocumentReadyState({ onChange });
|
||||
});
|
||||
|
||||
setReadyState('interactive');
|
||||
await nextTick();
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
const [state, previous, event] = onChange.mock.calls[0]!;
|
||||
expect(state).toBe('interactive');
|
||||
expect(previous).toBe('loading');
|
||||
expect(event).toBeInstanceOf(Event);
|
||||
|
||||
setReadyState('complete');
|
||||
await nextTick();
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(2);
|
||||
expect(onChange.mock.calls[1]!.slice(0, 2)).toEqual(['complete', 'interactive']);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not update or fire onChange when the state is unchanged', async () => {
|
||||
const onChange = vi.fn();
|
||||
const scope = effectScope();
|
||||
let readyState: ReturnType<typeof useDocumentReadyState>;
|
||||
scope.run(() => {
|
||||
readyState = useDocumentReadyState({ onChange });
|
||||
});
|
||||
|
||||
// readyState is already 'complete'; dispatching with no real change is a no-op
|
||||
document.dispatchEvent(new Event('readystatechange'));
|
||||
await nextTick();
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
expect(readyState!.value).toBe('complete');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('is SSR-safe and returns "loading" without a document', () => {
|
||||
// Passing `document: undefined` resolves to the default document, so to exercise the
|
||||
// no-document branch we cast a falsy value that bypasses the default-parameter logic.
|
||||
const scope = effectScope();
|
||||
let readyState: ReturnType<typeof useDocumentReadyState>;
|
||||
scope.run(() => {
|
||||
readyState = useDocumentReadyState({ document: null as unknown as Document });
|
||||
});
|
||||
|
||||
expect(readyState!.value).toBe('loading');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('accepts a custom document instance', async () => {
|
||||
const onChange = vi.fn();
|
||||
let listener: ((event: Event) => void) | undefined;
|
||||
const customDoc = {
|
||||
readyState: 'loading' as DocumentReadyState,
|
||||
addEventListener: (_type: string, cb: (event: Event) => void) => { listener = cb; },
|
||||
removeEventListener: vi.fn(),
|
||||
} as unknown as Document;
|
||||
|
||||
const scope = effectScope();
|
||||
let readyState: ReturnType<typeof useDocumentReadyState>;
|
||||
scope.run(() => {
|
||||
readyState = useDocumentReadyState({ document: customDoc, onChange });
|
||||
});
|
||||
|
||||
expect(readyState!.value).toBe('loading');
|
||||
|
||||
(customDoc as { readyState: DocumentReadyState }).readyState = 'complete';
|
||||
listener?.(new Event('readystatechange'));
|
||||
await nextTick();
|
||||
|
||||
expect(readyState!.value).toBe('complete');
|
||||
expect(onChange).toHaveBeenCalledWith('complete', 'loading', expect.any(Event));
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
import { shallowRef } from 'vue';
|
||||
import type { ShallowRef } from 'vue';
|
||||
import { defaultDocument } from '@/types';
|
||||
import type { ConfigurableDocument } from '@/types';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
|
||||
export interface UseDocumentReadyStateOptions extends ConfigurableDocument {
|
||||
/**
|
||||
* Called whenever `document.readyState` changes, receiving the new state,
|
||||
* the previous state, and the originating `readystatechange` event.
|
||||
*
|
||||
* @default undefined
|
||||
*/
|
||||
onChange?: (
|
||||
state: DocumentReadyState,
|
||||
previous: DocumentReadyState,
|
||||
event: Event,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export type UseDocumentReadyStateReturn = ShallowRef<DocumentReadyState>;
|
||||
|
||||
/**
|
||||
* @name useDocumentReadyState
|
||||
* @category Browser
|
||||
* @description Reactive `document.readyState` (`loading` | `interactive` | `complete`), updated on `readystatechange`.
|
||||
*
|
||||
* @param {UseDocumentReadyStateOptions} [options={}] Options (custom `document`, `onChange` callback)
|
||||
* @returns {UseDocumentReadyStateReturn} The current document ready state
|
||||
*
|
||||
* @example
|
||||
* const readyState = useDocumentReadyState();
|
||||
* watch(readyState, (state) => {
|
||||
* if (state === 'complete') runAfterLoad();
|
||||
* });
|
||||
*
|
||||
* @example
|
||||
* useDocumentReadyState({
|
||||
* onChange: (state) => {
|
||||
* if (state === 'interactive') hydrate();
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useDocumentReadyState(
|
||||
options: UseDocumentReadyStateOptions = {},
|
||||
): UseDocumentReadyStateReturn {
|
||||
const { document = defaultDocument, onChange } = options;
|
||||
|
||||
const readyState = shallowRef<DocumentReadyState>(document?.readyState ?? 'loading');
|
||||
|
||||
if (document) {
|
||||
useEventListener(document, 'readystatechange', (event) => {
|
||||
const previous = readyState.value;
|
||||
const state = document.readyState;
|
||||
|
||||
if (state === previous)
|
||||
return;
|
||||
|
||||
readyState.value = state;
|
||||
onChange?.(state, previous, event);
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
return readyState;
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick } from 'vue';
|
||||
import { useDocumentVisibility } from '.';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true });
|
||||
});
|
||||
|
||||
function setVisibility(state: DocumentVisibilityState) {
|
||||
Object.defineProperty(document, 'visibilityState', { value: state, configurable: true });
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
}
|
||||
|
||||
describe(useDocumentVisibility, () => {
|
||||
it('reads the current visibility state', () => {
|
||||
const scope = effectScope();
|
||||
let visibility: ReturnType<typeof useDocumentVisibility>;
|
||||
scope.run(() => {
|
||||
visibility = useDocumentVisibility();
|
||||
});
|
||||
|
||||
expect(visibility!.value).toBe('visible');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('updates on visibilitychange', async () => {
|
||||
const scope = effectScope();
|
||||
let visibility: ReturnType<typeof useDocumentVisibility>;
|
||||
scope.run(() => {
|
||||
visibility = useDocumentVisibility();
|
||||
});
|
||||
|
||||
setVisibility('hidden');
|
||||
await nextTick();
|
||||
|
||||
expect(visibility!.value).toBe('hidden');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('invokes onChange with new state, previous state, and the event', async () => {
|
||||
const onChange = vi.fn();
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
useDocumentVisibility({ onChange });
|
||||
});
|
||||
|
||||
setVisibility('hidden');
|
||||
await nextTick();
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
const [state, previous, event] = onChange.mock.calls[0]!;
|
||||
expect(state).toBe('hidden');
|
||||
expect(previous).toBe('visible');
|
||||
expect(event).toBeInstanceOf(Event);
|
||||
|
||||
setVisibility('visible');
|
||||
await nextTick();
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(2);
|
||||
expect(onChange.mock.calls[1]!.slice(0, 2)).toEqual(['visible', 'hidden']);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not update or fire onChange when the state is unchanged', async () => {
|
||||
const onChange = vi.fn();
|
||||
const scope = effectScope();
|
||||
let visibility: ReturnType<typeof useDocumentVisibility>;
|
||||
scope.run(() => {
|
||||
visibility = useDocumentVisibility({ onChange });
|
||||
});
|
||||
|
||||
// visibilityState is already 'visible'; dispatching with no real change is a no-op
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
await nextTick();
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
expect(visibility!.value).toBe('visible');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reflects a non-default initial state at setup time', () => {
|
||||
Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
|
||||
|
||||
const scope = effectScope();
|
||||
let visibility: ReturnType<typeof useDocumentVisibility>;
|
||||
scope.run(() => {
|
||||
visibility = useDocumentVisibility();
|
||||
});
|
||||
|
||||
expect(visibility!.value).toBe('hidden');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('is SSR-safe and returns "visible" without a document', () => {
|
||||
const scope = effectScope();
|
||||
let visibility: ReturnType<typeof useDocumentVisibility>;
|
||||
scope.run(() => {
|
||||
visibility = useDocumentVisibility({ document: undefined });
|
||||
});
|
||||
|
||||
expect(visibility!.value).toBe('visible');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('accepts a custom document instance', async () => {
|
||||
const onChange = vi.fn();
|
||||
let listener: ((event: Event) => void) | undefined;
|
||||
const customDoc = {
|
||||
visibilityState: 'visible' as DocumentVisibilityState,
|
||||
addEventListener: (_type: string, cb: (event: Event) => void) => { listener = cb; },
|
||||
removeEventListener: vi.fn(),
|
||||
} as unknown as Document;
|
||||
|
||||
const scope = effectScope();
|
||||
let visibility: ReturnType<typeof useDocumentVisibility>;
|
||||
scope.run(() => {
|
||||
visibility = useDocumentVisibility({ document: customDoc, onChange });
|
||||
});
|
||||
|
||||
expect(visibility!.value).toBe('visible');
|
||||
|
||||
(customDoc as { visibilityState: DocumentVisibilityState }).visibilityState = 'hidden';
|
||||
listener?.(new Event('visibilitychange'));
|
||||
await nextTick();
|
||||
|
||||
expect(visibility!.value).toBe('hidden');
|
||||
expect(onChange).toHaveBeenCalledWith('hidden', 'visible', expect.any(Event));
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
import { shallowRef } from 'vue';
|
||||
import type { ShallowRef } from 'vue';
|
||||
import { defaultDocument } from '@/types';
|
||||
import type { ConfigurableDocument } from '@/types';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
|
||||
export interface UseDocumentVisibilityOptions extends ConfigurableDocument {
|
||||
/**
|
||||
* Called whenever `document.visibilityState` changes, receiving the new state,
|
||||
* the previous state, and the originating `visibilitychange` event.
|
||||
*
|
||||
* @default undefined
|
||||
*/
|
||||
onChange?: (
|
||||
state: DocumentVisibilityState,
|
||||
previous: DocumentVisibilityState,
|
||||
event: Event,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export type UseDocumentVisibilityReturn = ShallowRef<DocumentVisibilityState>;
|
||||
|
||||
/**
|
||||
* @name useDocumentVisibility
|
||||
* @category Browser
|
||||
* @description Reactive `document.visibilityState`.
|
||||
*
|
||||
* @param {UseDocumentVisibilityOptions} [options={}] Options (custom `document`, `onChange` callback)
|
||||
* @returns {UseDocumentVisibilityReturn} The current visibility state
|
||||
*
|
||||
* @example
|
||||
* const visibility = useDocumentVisibility();
|
||||
* watch(visibility, (state) => {
|
||||
* if (state === 'visible') refresh();
|
||||
* });
|
||||
*
|
||||
* @example
|
||||
* useDocumentVisibility({
|
||||
* onChange: (state) => {
|
||||
* if (state === 'hidden') pausePlayback();
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useDocumentVisibility(
|
||||
options: UseDocumentVisibilityOptions = {},
|
||||
): UseDocumentVisibilityReturn {
|
||||
const { document = defaultDocument, onChange } = options;
|
||||
|
||||
const visibility = shallowRef<DocumentVisibilityState>(document?.visibilityState ?? 'visible');
|
||||
|
||||
if (document) {
|
||||
useEventListener(document, 'visibilitychange', (event) => {
|
||||
const previous = visibility.value;
|
||||
const state = document.visibilityState;
|
||||
|
||||
if (state === previous)
|
||||
return;
|
||||
|
||||
visibility.value = state;
|
||||
onChange?.(state, previous, event);
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
return visibility;
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick, ref } from 'vue';
|
||||
import { useDropZone } from '.';
|
||||
|
||||
interface FakeDataTransfer {
|
||||
files: File[];
|
||||
items: Array<{ type: string }>;
|
||||
dropEffect: string;
|
||||
}
|
||||
|
||||
function makeFile(name = 'a.png', type = 'image/png'): File {
|
||||
return new File(['x'], name, { type });
|
||||
}
|
||||
|
||||
// jsdom lacks DragEvent / DataTransfer, so we synthesize an Event with a dataTransfer payload.
|
||||
function dispatchDrag(
|
||||
el: EventTarget,
|
||||
type: 'dragenter' | 'dragover' | 'dragleave' | 'drop',
|
||||
files: File[] = [],
|
||||
): { event: Event; dataTransfer: FakeDataTransfer } {
|
||||
const dataTransfer: FakeDataTransfer = {
|
||||
files,
|
||||
items: files.map(f => ({ type: f.type })),
|
||||
dropEffect: 'none',
|
||||
};
|
||||
|
||||
const event = new Event(type, { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, 'dataTransfer', { value: dataTransfer, configurable: true });
|
||||
|
||||
el.dispatchEvent(event);
|
||||
return { event, dataTransfer };
|
||||
}
|
||||
|
||||
describe(useDropZone, () => {
|
||||
let el: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
el = document.createElement('div');
|
||||
document.body.appendChild(el);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
el.remove();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('exposes reactive state', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
const { isOverDropZone, files, isSupported } = useDropZone(el);
|
||||
expect(isOverDropZone.value).toBeFalsy();
|
||||
expect(files.value).toBeNull();
|
||||
expect(isSupported).toBeDefined();
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('sets isOverDropZone on dragenter and clears on matching dragleave', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
const { isOverDropZone } = useDropZone(el);
|
||||
|
||||
dispatchDrag(el, 'dragenter', [makeFile()]);
|
||||
expect(isOverDropZone.value).toBeTruthy();
|
||||
|
||||
dispatchDrag(el, 'dragleave', [makeFile()]);
|
||||
expect(isOverDropZone.value).toBeFalsy();
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('uses a counter so nested enter/leave keeps isOverDropZone true', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
const { isOverDropZone } = useDropZone(el);
|
||||
|
||||
dispatchDrag(el, 'dragenter', [makeFile()]);
|
||||
dispatchDrag(el, 'dragenter', [makeFile()]);
|
||||
expect(isOverDropZone.value).toBeTruthy();
|
||||
|
||||
dispatchDrag(el, 'dragleave', [makeFile()]);
|
||||
expect(isOverDropZone.value).toBeTruthy();
|
||||
|
||||
dispatchDrag(el, 'dragleave', [makeFile()]);
|
||||
expect(isOverDropZone.value).toBeFalsy();
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('collects dropped files and resets isOverDropZone', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
const { files, isOverDropZone } = useDropZone(el);
|
||||
|
||||
dispatchDrag(el, 'dragenter', [makeFile()]);
|
||||
const dropped = [makeFile('one.png'), makeFile('two.png')];
|
||||
dispatchDrag(el, 'drop', dropped);
|
||||
|
||||
expect(files.value).toHaveLength(2);
|
||||
expect(files.value?.[0]!.name).toBe('one.png');
|
||||
expect(isOverDropZone.value).toBeFalsy();
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('invokes lifecycle callbacks', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
const onEnter = vi.fn();
|
||||
const onOver = vi.fn();
|
||||
const onLeave = vi.fn();
|
||||
const onDrop = vi.fn();
|
||||
|
||||
useDropZone(el, { onEnter, onOver, onLeave, onDrop });
|
||||
|
||||
const f = [makeFile()];
|
||||
dispatchDrag(el, 'dragenter', f);
|
||||
dispatchDrag(el, 'dragover', f);
|
||||
dispatchDrag(el, 'dragleave', f);
|
||||
dispatchDrag(el, 'drop', f);
|
||||
|
||||
expect(onEnter).toHaveBeenCalledTimes(1);
|
||||
expect(onOver).toHaveBeenCalledTimes(1);
|
||||
expect(onLeave).toHaveBeenCalledTimes(1);
|
||||
expect(onDrop).toHaveBeenCalledTimes(1);
|
||||
expect(onEnter).toHaveBeenCalledWith(null, expect.any(Event));
|
||||
expect(onDrop.mock.calls[0]![0]).toHaveLength(1);
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('accepts a shorthand onDrop function as options', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
const onDrop = vi.fn();
|
||||
useDropZone(el, onDrop);
|
||||
|
||||
dispatchDrag(el, 'drop', [makeFile()]);
|
||||
expect(onDrop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('respects multiple: false by keeping only the first file', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
const { files } = useDropZone(el, { multiple: false });
|
||||
|
||||
// Two files dragged: validation should reject, so drop is ignored
|
||||
dispatchDrag(el, 'drop', [makeFile('a.png'), makeFile('b.png')]);
|
||||
expect(files.value).toBeNull();
|
||||
|
||||
// Single file passes and only the first is kept
|
||||
dispatchDrag(el, 'drop', [makeFile('solo.png')]);
|
||||
expect(files.value).toHaveLength(1);
|
||||
expect(files.value?.[0]!.name).toBe('solo.png');
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('filters by dataTypes array', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
const onDrop = vi.fn();
|
||||
const { files } = useDropZone(el, { dataTypes: ['image/png'], onDrop });
|
||||
|
||||
// wrong type rejected
|
||||
dispatchDrag(el, 'drop', [makeFile('doc.pdf', 'application/pdf')]);
|
||||
expect(files.value).toBeNull();
|
||||
expect(onDrop).not.toHaveBeenCalled();
|
||||
|
||||
// correct type accepted
|
||||
dispatchDrag(el, 'drop', [makeFile('img.png', 'image/png')]);
|
||||
expect(files.value).toHaveLength(1);
|
||||
expect(onDrop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('supports dataTypes as a predicate function', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
const predicate = vi.fn((types: readonly string[]) => types.includes('image/png'));
|
||||
const { files } = useDropZone(el, { dataTypes: predicate });
|
||||
|
||||
dispatchDrag(el, 'drop', [makeFile('img.png', 'image/png')]);
|
||||
expect(predicate).toHaveBeenCalled();
|
||||
expect(files.value).toHaveLength(1);
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reacts to a reactive dataTypes ref', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
const allowed = ref<string[]>(['image/png']);
|
||||
const { files } = useDropZone(el, { dataTypes: allowed });
|
||||
|
||||
dispatchDrag(el, 'drop', [makeFile('doc.pdf', 'application/pdf')]);
|
||||
expect(files.value).toBeNull();
|
||||
|
||||
allowed.value = ['application/pdf'];
|
||||
dispatchDrag(el, 'drop', [makeFile('doc.pdf', 'application/pdf')]);
|
||||
expect(files.value).toHaveLength(1);
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('sets dropEffect to none for invalid drags', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
useDropZone(el, { dataTypes: ['image/png'] });
|
||||
|
||||
const { dataTransfer } = dispatchDrag(el, 'dragenter', [makeFile('doc.pdf', 'application/pdf')]);
|
||||
expect(dataTransfer.dropEffect).toBe('none');
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('sets dropEffect to copy for valid drags', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
useDropZone(el, { dataTypes: ['image/png'] });
|
||||
|
||||
const { dataTransfer } = dispatchDrag(el, 'dragenter', [makeFile('img.png', 'image/png')]);
|
||||
expect(dataTransfer.dropEffect).toBe('copy');
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('preventDefaultForUnhandled calls preventDefault on invalid drags', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
useDropZone(el, { dataTypes: ['image/png'], preventDefaultForUnhandled: true });
|
||||
|
||||
const { event } = dispatchDrag(el, 'dragenter', [makeFile('doc.pdf', 'application/pdf')]);
|
||||
expect(event.defaultPrevented).toBeTruthy();
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('works with a reactive element ref target', async () => {
|
||||
const scope = effectScope();
|
||||
await scope.run(async () => {
|
||||
const target = ref<HTMLElement | null>(null);
|
||||
const { isOverDropZone } = useDropZone(target);
|
||||
|
||||
target.value = el;
|
||||
await nextTick();
|
||||
|
||||
dispatchDrag(el, 'dragenter', [makeFile()]);
|
||||
expect(isOverDropZone.value).toBeTruthy();
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('works with document as the target', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
const { isOverDropZone } = useDropZone(document);
|
||||
|
||||
dispatchDrag(document, 'dragenter', [makeFile()]);
|
||||
expect(isOverDropZone.value).toBeTruthy();
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('stops listening after the scope is disposed', () => {
|
||||
const onDrop = vi.fn();
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
useDropZone(el, { onDrop });
|
||||
});
|
||||
scope.stop();
|
||||
|
||||
dispatchDrag(el, 'drop', [makeFile()]);
|
||||
expect(onDrop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports isSupported via the configurable window option', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
const { isSupported } = useDropZone(el, { window: undefined });
|
||||
expect(isSupported.value).toBeFalsy();
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -1,205 +0,0 @@
|
||||
import type { ComputedRef, MaybeRef, MaybeRefOrGetter, ShallowRef } from 'vue';
|
||||
import { shallowRef, toValue, unref } from 'vue';
|
||||
import { isFunction } from '@robonen/stdlib';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
import { useSupported } from '@/composables/browser/useSupported';
|
||||
import { unrefElement } from '@/composables/component/unrefElement';
|
||||
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
||||
import { defaultNavigator, defaultWindow } from '@/types';
|
||||
import type { ConfigurableNavigator, ConfigurableWindow } from '@/types';
|
||||
|
||||
export type UseDropZoneDataTypes = MaybeRef<readonly string[]> | ((types: readonly string[]) => boolean);
|
||||
|
||||
export interface UseDropZoneOptions extends ConfigurableWindow, ConfigurableNavigator {
|
||||
/**
|
||||
* Allowed data types. If not set, all data types are allowed.
|
||||
* Can also be a predicate that receives the dragged item types and returns whether they are valid.
|
||||
*/
|
||||
dataTypes?: UseDropZoneDataTypes;
|
||||
/**
|
||||
* Allow multiple files to be dropped.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
multiple?: boolean;
|
||||
/**
|
||||
* Call `preventDefault` even for drags that fail validation, suppressing the browser's default handling.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
preventDefaultForUnhandled?: boolean;
|
||||
/**
|
||||
* Fired when valid files are dropped on the target.
|
||||
*/
|
||||
onDrop?: (files: File[] | null, event: DragEvent) => void;
|
||||
/**
|
||||
* Fired when a drag enters the target.
|
||||
*/
|
||||
onEnter?: (files: File[] | null, event: DragEvent) => void;
|
||||
/**
|
||||
* Fired when a drag leaves the target.
|
||||
*/
|
||||
onLeave?: (files: File[] | null, event: DragEvent) => void;
|
||||
/**
|
||||
* Fired repeatedly while a drag hovers over the target.
|
||||
*/
|
||||
onOver?: (files: File[] | null, event: DragEvent) => void;
|
||||
}
|
||||
|
||||
export interface UseDropZoneReturn {
|
||||
/**
|
||||
* Whether a valid drag is currently hovering over the target.
|
||||
*/
|
||||
isOverDropZone: ShallowRef<boolean>;
|
||||
/**
|
||||
* The dropped files, or `null` when nothing has been dropped yet.
|
||||
*/
|
||||
files: ShallowRef<File[] | null>;
|
||||
/**
|
||||
* Whether the Drag and Drop API is available in the current environment.
|
||||
*/
|
||||
isSupported: ComputedRef<boolean>;
|
||||
}
|
||||
|
||||
type DropZoneEventType = 'enter' | 'over' | 'leave' | 'drop';
|
||||
|
||||
/**
|
||||
* @name useDropZone
|
||||
* @category Browser
|
||||
* @description Create a drag-and-drop file drop zone on a target element or document.
|
||||
*
|
||||
* @param {MaybeComputedElementRef | MaybeRefOrGetter<Document | null | undefined>} target - The element (or document) acting as the drop zone.
|
||||
* @param {UseDropZoneOptions | UseDropZoneOptions['onDrop']} [options] - Drop zone options, or a shorthand `onDrop` callback.
|
||||
* @returns {UseDropZoneReturn} The reactive drop zone state.
|
||||
*
|
||||
* @example
|
||||
* const dropZone = useTemplateRef<HTMLElement>('dropZone');
|
||||
* const { isOverDropZone, files } = useDropZone(dropZone, {
|
||||
* dataTypes: ['image/png'],
|
||||
* onDrop: (files) => console.log(files),
|
||||
* });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useDropZone(
|
||||
target: MaybeComputedElementRef | MaybeRefOrGetter<Document | null | undefined>,
|
||||
options: UseDropZoneOptions | UseDropZoneOptions['onDrop'] = {},
|
||||
): UseDropZoneReturn {
|
||||
const _options: UseDropZoneOptions = isFunction(options) ? { onDrop: options } : options;
|
||||
const {
|
||||
window = defaultWindow,
|
||||
navigator = defaultNavigator,
|
||||
multiple = true,
|
||||
preventDefaultForUnhandled = false,
|
||||
} = _options;
|
||||
|
||||
const isOverDropZone = shallowRef(false);
|
||||
const files = shallowRef<File[] | null>(null);
|
||||
const isSupported = useSupported(() => window && 'DataTransfer' in window);
|
||||
|
||||
let counter = 0;
|
||||
let isValid = true;
|
||||
|
||||
const getFiles = (event: DragEvent): File[] | null => {
|
||||
const list = Array.from(event.dataTransfer?.files ?? []);
|
||||
if (list.length === 0)
|
||||
return null;
|
||||
return multiple ? list : [list[0]!];
|
||||
};
|
||||
|
||||
const checkDataTypes = (types: readonly string[]): boolean => {
|
||||
// `dataTypes` may be a predicate function, so unwrap with `unref` (not `toValue`,
|
||||
// which would call a function as a getter).
|
||||
const dataTypes = unref(_options.dataTypes);
|
||||
|
||||
if (isFunction(dataTypes))
|
||||
return dataTypes(types);
|
||||
|
||||
if (!dataTypes?.length)
|
||||
return true;
|
||||
|
||||
if (types.length === 0)
|
||||
return false;
|
||||
|
||||
return types.every(type => dataTypes.some(allowed => type.includes(allowed)));
|
||||
};
|
||||
|
||||
const checkValidity = (items: DataTransferItemList): boolean => {
|
||||
const types = Array.from(items ?? []).map(item => item.type);
|
||||
const dataTypesValid = checkDataTypes(types);
|
||||
const multipleFilesValid = multiple || items.length <= 1;
|
||||
|
||||
return dataTypesValid && multipleFilesValid;
|
||||
};
|
||||
|
||||
// Safari fires drag events without populating `dataTransfer.items`, so validation
|
||||
// cannot be trusted there — always accept the drag and let `drop` resolve files.
|
||||
const isSafari = (): boolean => {
|
||||
if (!navigator || !window)
|
||||
return false;
|
||||
return /^(?:(?!chrome|android).)*safari/i.test(navigator.userAgent) && !('chrome' in window);
|
||||
};
|
||||
|
||||
const handleDragEvent = (event: DragEvent, type: DropZoneEventType): void => {
|
||||
const items = event.dataTransfer?.items;
|
||||
isValid = (items && checkValidity(items)) ?? false;
|
||||
|
||||
if (preventDefaultForUnhandled)
|
||||
event.preventDefault();
|
||||
|
||||
if (!isSafari() && !isValid) {
|
||||
if (event.dataTransfer)
|
||||
event.dataTransfer.dropEffect = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer)
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
|
||||
const currentFiles = getFiles(event);
|
||||
|
||||
switch (type) {
|
||||
case 'enter':
|
||||
counter += 1;
|
||||
isOverDropZone.value = true;
|
||||
_options.onEnter?.(null, event);
|
||||
break;
|
||||
case 'over':
|
||||
_options.onOver?.(null, event);
|
||||
break;
|
||||
case 'leave':
|
||||
counter -= 1;
|
||||
if (counter === 0)
|
||||
isOverDropZone.value = false;
|
||||
_options.onLeave?.(null, event);
|
||||
break;
|
||||
case 'drop':
|
||||
counter = 0;
|
||||
isOverDropZone.value = false;
|
||||
if (isValid) {
|
||||
files.value = currentFiles;
|
||||
_options.onDrop?.(currentFiles, event);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveTarget = (): EventTarget | null | undefined => {
|
||||
const value = toValue(target as MaybeRefOrGetter<unknown>);
|
||||
if (value instanceof Document)
|
||||
return value;
|
||||
return unrefElement(target as MaybeComputedElementRef);
|
||||
};
|
||||
|
||||
useEventListener<DragEvent>(resolveTarget, 'dragenter', event => handleDragEvent(event, 'enter'));
|
||||
useEventListener<DragEvent>(resolveTarget, 'dragover', event => handleDragEvent(event, 'over'));
|
||||
useEventListener<DragEvent>(resolveTarget, 'dragleave', event => handleDragEvent(event, 'leave'));
|
||||
useEventListener<DragEvent>(resolveTarget, 'drop', event => handleDragEvent(event, 'drop'));
|
||||
|
||||
return {
|
||||
isOverDropZone,
|
||||
files,
|
||||
isSupported,
|
||||
};
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, ref } from 'vue';
|
||||
import { useElementBounding } from '.';
|
||||
|
||||
class StubObserver {
|
||||
observe = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
unobserve = vi.fn();
|
||||
takeRecords = vi.fn(() => []);
|
||||
}
|
||||
|
||||
describe(useElementBounding, () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', StubObserver);
|
||||
vi.stubGlobal('MutationObserver', StubObserver);
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('reads the bounding rect immediately', () => {
|
||||
const el = document.createElement('div');
|
||||
el.getBoundingClientRect = () => ({
|
||||
width: 100, height: 50, top: 10, left: 20, right: 120, bottom: 60, x: 20, y: 10,
|
||||
} as DOMRect);
|
||||
|
||||
const scope = effectScope();
|
||||
let bounds: ReturnType<typeof useElementBounding>;
|
||||
scope.run(() => {
|
||||
bounds = useElementBounding(ref(el));
|
||||
});
|
||||
|
||||
expect(bounds!.width.value).toBe(100);
|
||||
expect(bounds!.height.value).toBe(50);
|
||||
expect(bounds!.top.value).toBe(10);
|
||||
expect(bounds!.left.value).toBe(20);
|
||||
expect(bounds!.x.value).toBe(20);
|
||||
expect(bounds!.y.value).toBe(10);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('update recomputes the rect', () => {
|
||||
const el = document.createElement('div');
|
||||
let w = 10;
|
||||
el.getBoundingClientRect = () => ({ width: w, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0 } as DOMRect);
|
||||
|
||||
const scope = effectScope();
|
||||
let bounds: ReturnType<typeof useElementBounding>;
|
||||
scope.run(() => {
|
||||
bounds = useElementBounding(ref(el));
|
||||
});
|
||||
|
||||
expect(bounds!.width.value).toBe(10);
|
||||
w = 200;
|
||||
bounds!.update();
|
||||
expect(bounds!.width.value).toBe(200);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('resets to zero when target is null', () => {
|
||||
const scope = effectScope();
|
||||
let bounds: ReturnType<typeof useElementBounding>;
|
||||
scope.run(() => {
|
||||
bounds = useElementBounding(ref(null));
|
||||
});
|
||||
|
||||
expect(bounds!.width.value).toBe(0);
|
||||
expect(bounds!.height.value).toBe(0);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
// NOTE: defaultWindow is captured at import time, so vi.stubGlobal does not
|
||||
// reach requestAnimationFrame. We inject a fake window via the `window` option.
|
||||
it('defers measurement to the next frame with updateTiming "next-frame"', () => {
|
||||
const raf = vi.fn((cb: FrameRequestCallback) => {
|
||||
cb(0);
|
||||
return 1;
|
||||
});
|
||||
const fakeWindow = { requestAnimationFrame: raf, cancelAnimationFrame: vi.fn() } as unknown as Window;
|
||||
|
||||
const el = document.createElement('div');
|
||||
let w = 10;
|
||||
el.getBoundingClientRect = () => ({ width: w, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0 } as DOMRect);
|
||||
|
||||
const scope = effectScope();
|
||||
let bounds: ReturnType<typeof useElementBounding>;
|
||||
scope.run(() => {
|
||||
bounds = useElementBounding(ref(el), { updateTiming: 'next-frame', window: fakeWindow });
|
||||
});
|
||||
|
||||
// The immediate update went through requestAnimationFrame
|
||||
expect(raf).toHaveBeenCalled();
|
||||
expect(bounds!.width.value).toBe(10);
|
||||
|
||||
w = 200;
|
||||
bounds!.update();
|
||||
expect(bounds!.width.value).toBe(200);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('coalesces multiple "next-frame" updates into a single read per frame', () => {
|
||||
let scheduled: FrameRequestCallback | undefined;
|
||||
const raf = vi.fn((cb: FrameRequestCallback) => {
|
||||
scheduled = cb;
|
||||
return 1;
|
||||
});
|
||||
const fakeWindow = { requestAnimationFrame: raf, cancelAnimationFrame: vi.fn() } as unknown as Window;
|
||||
|
||||
const el = document.createElement('div');
|
||||
const getRect = vi.fn(() => ({ width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0 } as DOMRect));
|
||||
el.getBoundingClientRect = getRect;
|
||||
|
||||
const scope = effectScope();
|
||||
let bounds: ReturnType<typeof useElementBounding>;
|
||||
scope.run(() => {
|
||||
bounds = useElementBounding(ref(el), { updateTiming: 'next-frame', immediate: false, window: fakeWindow });
|
||||
});
|
||||
|
||||
bounds!.update();
|
||||
bounds!.update();
|
||||
bounds!.update();
|
||||
|
||||
// Only one frame was scheduled despite three update() calls
|
||||
expect(raf).toHaveBeenCalledTimes(1);
|
||||
expect(getRect).not.toHaveBeenCalled();
|
||||
|
||||
// Flushing the frame reads the rect exactly once
|
||||
scheduled!(0);
|
||||
expect(getRect).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A new update after the frame flushed schedules a fresh frame
|
||||
bounds!.update();
|
||||
expect(raf).toHaveBeenCalledTimes(2);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('cancels a pending frame on scope dispose', () => {
|
||||
const raf = vi.fn(() => 42);
|
||||
const caf = vi.fn();
|
||||
const fakeWindow = { requestAnimationFrame: raf, cancelAnimationFrame: caf } as unknown as Window;
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.getBoundingClientRect = () => ({ width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0 } as DOMRect);
|
||||
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
useElementBounding(ref(el), { updateTiming: 'next-frame', window: fakeWindow });
|
||||
});
|
||||
|
||||
// The immediate update scheduled a frame that never ran (raf returns id without invoking)
|
||||
expect(raf).toHaveBeenCalled();
|
||||
scope.stop();
|
||||
expect(caf).toHaveBeenCalledWith(42);
|
||||
});
|
||||
});
|
||||
@@ -1,199 +0,0 @@
|
||||
import { shallowRef, watch } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
||||
import { unrefElement } from '@/composables/component/unrefElement';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
import { useResizeObserver } from '@/composables/browser/useResizeObserver';
|
||||
import { useMutationObserver } from '@/composables/browser/useMutationObserver';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
|
||||
export interface UseElementBoundingOptions extends ConfigurableWindow {
|
||||
/**
|
||||
* Reset values to 0 when the element is unmounted
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
reset?: boolean;
|
||||
|
||||
/**
|
||||
* Recalculate on window resize
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
windowResize?: boolean;
|
||||
|
||||
/**
|
||||
* Recalculate on window scroll
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
windowScroll?: boolean;
|
||||
|
||||
/**
|
||||
* Calculate immediately on mount
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
immediate?: boolean;
|
||||
|
||||
/**
|
||||
* When to recalculate the bounding box.
|
||||
*
|
||||
* - `'sync'` measures synchronously, the moment a trigger fires.
|
||||
* - `'next-frame'` defers measurement to the next animation frame. This
|
||||
* batches bursts of triggers (e.g. rapid scroll/resize) into a single
|
||||
* read per frame, avoiding repeated layout thrash from `getBoundingClientRect`.
|
||||
*
|
||||
* @default 'sync'
|
||||
*/
|
||||
updateTiming?: 'sync' | 'next-frame';
|
||||
}
|
||||
|
||||
export interface UseElementBoundingReturn {
|
||||
height: Ref<number>;
|
||||
width: Ref<number>;
|
||||
top: Ref<number>;
|
||||
right: Ref<number>;
|
||||
bottom: Ref<number>;
|
||||
left: Ref<number>;
|
||||
x: Ref<number>;
|
||||
y: Ref<number>;
|
||||
/**
|
||||
* Manually recalculate the bounding box, honouring `updateTiming`.
|
||||
*/
|
||||
update: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useElementBounding
|
||||
* @category Browser
|
||||
* @description Reactive bounding box of an element (`getBoundingClientRect`),
|
||||
* kept in sync via `ResizeObserver`, `MutationObserver`, and window scroll/resize.
|
||||
* Supports deferring reads to the next animation frame to avoid layout thrash.
|
||||
*
|
||||
* @param {MaybeComputedElementRef} target Element to measure
|
||||
* @param {UseElementBoundingOptions} [options={}] Options
|
||||
* @returns {UseElementBoundingReturn} Reactive bounds and a manual `update`
|
||||
*
|
||||
* @example
|
||||
* const { width, height, top, left } = useElementBounding(el);
|
||||
*
|
||||
* @example
|
||||
* // Batch rapid scroll/resize reads into one measurement per frame
|
||||
* const bounds = useElementBounding(el, { updateTiming: 'next-frame' });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useElementBounding(
|
||||
target: MaybeComputedElementRef,
|
||||
options: UseElementBoundingOptions = {},
|
||||
): UseElementBoundingReturn {
|
||||
const {
|
||||
reset = true,
|
||||
windowResize = true,
|
||||
windowScroll = true,
|
||||
immediate = true,
|
||||
updateTiming = 'sync',
|
||||
window = defaultWindow,
|
||||
} = options;
|
||||
|
||||
const height = shallowRef(0);
|
||||
const width = shallowRef(0);
|
||||
const top = shallowRef(0);
|
||||
const right = shallowRef(0);
|
||||
const bottom = shallowRef(0);
|
||||
const left = shallowRef(0);
|
||||
const x = shallowRef(0);
|
||||
const y = shallowRef(0);
|
||||
|
||||
function recalculate() {
|
||||
const el = unrefElement(target);
|
||||
|
||||
if (!el) {
|
||||
if (reset) {
|
||||
height.value = 0;
|
||||
width.value = 0;
|
||||
top.value = 0;
|
||||
right.value = 0;
|
||||
bottom.value = 0;
|
||||
left.value = 0;
|
||||
x.value = 0;
|
||||
y.value = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
|
||||
height.value = rect.height;
|
||||
width.value = rect.width;
|
||||
top.value = rect.top;
|
||||
right.value = rect.right;
|
||||
bottom.value = rect.bottom;
|
||||
left.value = rect.left;
|
||||
x.value = rect.x;
|
||||
y.value = rect.y;
|
||||
}
|
||||
|
||||
// Pending animation frame id, so deferred reads coalesce and can be cancelled.
|
||||
// `pending` is the source of truth for coalescing; `rafId` is only kept for
|
||||
// cancellation. A separate flag avoids ordering bugs when the scheduler runs
|
||||
// the callback synchronously (the assignment below would otherwise clobber the
|
||||
// id the callback just cleared).
|
||||
let pending = false;
|
||||
let rafId: number | undefined;
|
||||
|
||||
function update() {
|
||||
if (updateTiming === 'next-frame' && window) {
|
||||
// Coalesce: only schedule one read per frame
|
||||
if (pending)
|
||||
return;
|
||||
|
||||
pending = true;
|
||||
rafId = window.requestAnimationFrame(() => {
|
||||
pending = false;
|
||||
rafId = undefined;
|
||||
recalculate();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
recalculate();
|
||||
}
|
||||
|
||||
useResizeObserver(target, update);
|
||||
watch(() => unrefElement(target), el => !el && update());
|
||||
useMutationObserver(target, update, { attributeFilter: ['style', 'class'] });
|
||||
|
||||
if (windowScroll)
|
||||
useEventListener('scroll', update, { capture: true, passive: true });
|
||||
|
||||
if (windowResize)
|
||||
useEventListener('resize', update, { passive: true });
|
||||
|
||||
if (window && immediate)
|
||||
update();
|
||||
|
||||
// Cancel any pending frame so we don't read a detached/disposed element
|
||||
tryOnScopeDispose(() => {
|
||||
if (pending && rafId !== undefined && window)
|
||||
window.cancelAnimationFrame(rafId);
|
||||
|
||||
pending = false;
|
||||
rafId = undefined;
|
||||
});
|
||||
|
||||
return {
|
||||
height,
|
||||
width,
|
||||
top,
|
||||
right,
|
||||
bottom,
|
||||
left,
|
||||
x,
|
||||
y,
|
||||
update,
|
||||
};
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, isReadonly, ref } from 'vue';
|
||||
import { useElementHover } from '.';
|
||||
|
||||
function dispatch(el: HTMLElement, type: 'mouseenter' | 'mouseleave') {
|
||||
el.dispatchEvent(new Event(type));
|
||||
}
|
||||
|
||||
describe(useElementHover, () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('is false initially', () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let isHovered: ReturnType<typeof useElementHover>;
|
||||
scope.run(() => {
|
||||
isHovered = useElementHover(ref(el));
|
||||
});
|
||||
|
||||
expect(isHovered!.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('toggles on mouseenter / mouseleave', () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let isHovered: ReturnType<typeof useElementHover>;
|
||||
scope.run(() => {
|
||||
isHovered = useElementHover(ref(el));
|
||||
});
|
||||
|
||||
dispatch(el, 'mouseenter');
|
||||
expect(isHovered!.value).toBeTruthy();
|
||||
|
||||
dispatch(el, 'mouseleave');
|
||||
expect(isHovered!.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('returns a writable shallow ref (not readonly)', () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let isHovered: ReturnType<typeof useElementHover>;
|
||||
scope.run(() => {
|
||||
isHovered = useElementHover(ref(el));
|
||||
});
|
||||
|
||||
expect(isReadonly(isHovered!)).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('respects delayEnter', () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let isHovered: ReturnType<typeof useElementHover>;
|
||||
scope.run(() => {
|
||||
isHovered = useElementHover(ref(el), { delayEnter: 100 });
|
||||
});
|
||||
|
||||
dispatch(el, 'mouseenter');
|
||||
expect(isHovered!.value).toBeFalsy();
|
||||
|
||||
vi.advanceTimersByTime(99);
|
||||
expect(isHovered!.value).toBeFalsy();
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(isHovered!.value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('respects delayLeave', () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let isHovered: ReturnType<typeof useElementHover>;
|
||||
scope.run(() => {
|
||||
isHovered = useElementHover(ref(el), { delayLeave: 200 });
|
||||
});
|
||||
|
||||
dispatch(el, 'mouseenter');
|
||||
expect(isHovered!.value).toBeTruthy();
|
||||
|
||||
dispatch(el, 'mouseleave');
|
||||
expect(isHovered!.value).toBeTruthy();
|
||||
|
||||
vi.advanceTimersByTime(199);
|
||||
expect(isHovered!.value).toBeTruthy();
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(isHovered!.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('cancels a pending enter timer when leaving before the delay elapses', () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let isHovered: ReturnType<typeof useElementHover>;
|
||||
scope.run(() => {
|
||||
isHovered = useElementHover(ref(el), { delayEnter: 100 });
|
||||
});
|
||||
|
||||
dispatch(el, 'mouseenter');
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(isHovered!.value).toBeFalsy();
|
||||
|
||||
// Leaving cancels the pending enter; with no leave delay it settles to false.
|
||||
dispatch(el, 'mouseleave');
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(isHovered!.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('cancels a pending leave timer when re-entering before the delay elapses', () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let isHovered: ReturnType<typeof useElementHover>;
|
||||
scope.run(() => {
|
||||
isHovered = useElementHover(ref(el), { delayLeave: 200 });
|
||||
});
|
||||
|
||||
dispatch(el, 'mouseenter');
|
||||
expect(isHovered!.value).toBeTruthy();
|
||||
|
||||
dispatch(el, 'mouseleave');
|
||||
vi.advanceTimersByTime(100);
|
||||
// Re-enter before the leave delay finishes: stays hovered, no flip.
|
||||
dispatch(el, 'mouseenter');
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(isHovered!.value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('stops listening once the scope is disposed', () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let isHovered: ReturnType<typeof useElementHover>;
|
||||
scope.run(() => {
|
||||
isHovered = useElementHover(ref(el));
|
||||
});
|
||||
|
||||
scope.stop();
|
||||
|
||||
dispatch(el, 'mouseenter');
|
||||
expect(isHovered!.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('clears a pending timer on scope dispose (no late update)', () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let isHovered: ReturnType<typeof useElementHover>;
|
||||
scope.run(() => {
|
||||
isHovered = useElementHover(ref(el), { delayEnter: 100 });
|
||||
});
|
||||
|
||||
dispatch(el, 'mouseenter');
|
||||
scope.stop();
|
||||
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(isHovered!.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('returns a static ref and registers no listeners when window is falsy (SSR)', () => {
|
||||
// `defaultWindow` is captured at import time and cannot be stubbed via
|
||||
// vi.stubGlobal, so we cast a falsy window through options to exercise the
|
||||
// SSR early-return without touching the global. Note that passing literal
|
||||
// `undefined` would resolve back to `defaultWindow` via the destructure
|
||||
// default, hence the explicit null cast here.
|
||||
const el = document.createElement('div');
|
||||
const addSpy = vi.spyOn(el, 'addEventListener');
|
||||
const scope = effectScope();
|
||||
let isHovered: ReturnType<typeof useElementHover>;
|
||||
scope.run(() => {
|
||||
isHovered = useElementHover(ref(el), { window: null as unknown as Window });
|
||||
});
|
||||
|
||||
expect(isHovered!.value).toBeFalsy();
|
||||
expect(addSpy).not.toHaveBeenCalled();
|
||||
|
||||
dispatch(el, 'mouseenter');
|
||||
expect(isHovered!.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
import { computed, shallowRef } from 'vue';
|
||||
import type { ShallowRef } from 'vue';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
||||
import { unrefElement } from '@/composables/component/unrefElement';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
|
||||
export interface UseElementHoverOptions extends ConfigurableWindow {
|
||||
/**
|
||||
* Delay in milliseconds before flipping the state to hovered on `mouseenter`.
|
||||
*
|
||||
* @default 0
|
||||
*/
|
||||
delayEnter?: number;
|
||||
|
||||
/**
|
||||
* Delay in milliseconds before flipping the state to not-hovered on `mouseleave`.
|
||||
*
|
||||
* @default 0
|
||||
*/
|
||||
delayLeave?: number;
|
||||
}
|
||||
|
||||
export type UseElementHoverReturn = ShallowRef<boolean>;
|
||||
|
||||
/**
|
||||
* @name useElementHover
|
||||
* @category Browser
|
||||
* @description Reactive hover state of an element, driven by `mouseenter` /
|
||||
* `mouseleave`. Supports independent enter/leave delays to debounce flicker.
|
||||
*
|
||||
* @param {MaybeComputedElementRef} target Element to track (ref, getter, or component instance)
|
||||
* @param {UseElementHoverOptions} [options={}] Options (`delayEnter`, `delayLeave`, `window`)
|
||||
* @returns {UseElementHoverReturn} Reactive hover state ref
|
||||
*
|
||||
* @example
|
||||
* const el = useTemplateRef('el');
|
||||
* const isHovered = useElementHover(el);
|
||||
*
|
||||
* @example
|
||||
* const isHovered = useElementHover(el, { delayEnter: 100, delayLeave: 200 });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useElementHover(
|
||||
target: MaybeComputedElementRef,
|
||||
options: UseElementHoverOptions = {},
|
||||
): UseElementHoverReturn {
|
||||
const {
|
||||
delayEnter = 0,
|
||||
delayLeave = 0,
|
||||
window = defaultWindow,
|
||||
} = options;
|
||||
|
||||
const isHovered = shallowRef(false);
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const clear = (): void => {
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = (entering: boolean): void => {
|
||||
const delay = entering ? delayEnter : delayLeave;
|
||||
|
||||
clear();
|
||||
|
||||
if (delay)
|
||||
timer = setTimeout(() => { isHovered.value = entering; }, delay);
|
||||
else
|
||||
isHovered.value = entering;
|
||||
};
|
||||
|
||||
// SSR / no DOM: return a static, never-updating ref.
|
||||
if (!window)
|
||||
return isHovered;
|
||||
|
||||
const targetElement = computed(() => unrefElement(target) as HTMLElement | undefined | null);
|
||||
|
||||
useEventListener(targetElement, 'mouseenter', () => toggle(true), { passive: true });
|
||||
useEventListener(targetElement, 'mouseleave', () => toggle(false), { passive: true });
|
||||
|
||||
tryOnScopeDispose(clear);
|
||||
|
||||
return isHovered;
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick, ref } from 'vue';
|
||||
import { useElementSize } from '.';
|
||||
|
||||
interface StubInstance {
|
||||
cb: ResizeObserverCallback;
|
||||
observe: ReturnType<typeof vi.fn>;
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
unobserve: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
let instances: StubInstance[] = [];
|
||||
|
||||
class StubResizeObserver {
|
||||
observe = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
unobserve = vi.fn();
|
||||
cb: ResizeObserverCallback;
|
||||
constructor(cb: ResizeObserverCallback) {
|
||||
this.cb = cb;
|
||||
instances.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
function fire(width: number, height: number, fields: Partial<ResizeObserverEntry> = {}) {
|
||||
instances[0]!.cb([
|
||||
{
|
||||
contentBoxSize: [{ inlineSize: width, blockSize: height }],
|
||||
contentRect: { width, height },
|
||||
...fields,
|
||||
} as unknown as ResizeObserverEntry,
|
||||
], {} as ResizeObserver);
|
||||
}
|
||||
|
||||
describe(useElementSize, () => {
|
||||
beforeEach(() => {
|
||||
instances = [];
|
||||
vi.stubGlobal('ResizeObserver', StubResizeObserver);
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('uses the initial size when the target resolves to no element', () => {
|
||||
const scope = effectScope();
|
||||
let size: ReturnType<typeof useElementSize>;
|
||||
scope.run(() => {
|
||||
size = useElementSize(ref(undefined), { width: 5, height: 7 });
|
||||
});
|
||||
|
||||
expect(size!.width.value).toBe(5);
|
||||
expect(size!.height.value).toBe(7);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('measures synchronously on mount via offset size', () => {
|
||||
const el = document.createElement('div');
|
||||
Object.defineProperty(el, 'offsetWidth', { value: 80, configurable: true });
|
||||
Object.defineProperty(el, 'offsetHeight', { value: 60, configurable: true });
|
||||
const scope = effectScope();
|
||||
let size: ReturnType<typeof useElementSize>;
|
||||
scope.run(() => {
|
||||
size = useElementSize(ref(el), { width: 5, height: 7 });
|
||||
});
|
||||
|
||||
// tryOnMounted runs synchronously outside a component, overwriting the initial size.
|
||||
expect(size!.width.value).toBe(80);
|
||||
expect(size!.height.value).toBe(60);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reports size from contentBoxSize', () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let size: ReturnType<typeof useElementSize>;
|
||||
scope.run(() => {
|
||||
size = useElementSize(ref(el));
|
||||
});
|
||||
|
||||
instances[0]!.cb([
|
||||
{ contentBoxSize: [{ inlineSize: 100, blockSize: 50 }], contentRect: { width: 0, height: 0 } } as unknown as ResizeObserverEntry,
|
||||
], {} as ResizeObserver);
|
||||
|
||||
expect(size!.width.value).toBe(100);
|
||||
expect(size!.height.value).toBe(50);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('falls back to contentRect when box sizes are missing', () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let size: ReturnType<typeof useElementSize>;
|
||||
scope.run(() => {
|
||||
size = useElementSize(ref(el));
|
||||
});
|
||||
|
||||
instances[0]!.cb([
|
||||
{ contentBoxSize: undefined, contentRect: { width: 30, height: 40 } } as unknown as ResizeObserverEntry,
|
||||
], {} as ResizeObserver);
|
||||
|
||||
expect(size!.width.value).toBe(30);
|
||||
expect(size!.height.value).toBe(40);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('normalises a single (non-array) ResizeObserverSize object', () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let size: ReturnType<typeof useElementSize>;
|
||||
scope.run(() => {
|
||||
size = useElementSize(ref(el));
|
||||
});
|
||||
|
||||
// Older Firefox reports box sizes as a single object rather than an array.
|
||||
instances[0]!.cb([
|
||||
{ contentBoxSize: { inlineSize: 12, blockSize: 34 }, contentRect: { width: 0, height: 0 } } as unknown as ResizeObserverEntry,
|
||||
], {} as ResizeObserver);
|
||||
|
||||
expect(size!.width.value).toBe(12);
|
||||
expect(size!.height.value).toBe(34);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('sums multiple box fragments in a single pass', () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let size: ReturnType<typeof useElementSize>;
|
||||
scope.run(() => {
|
||||
size = useElementSize(ref(el));
|
||||
});
|
||||
|
||||
instances[0]!.cb([
|
||||
{
|
||||
contentBoxSize: [
|
||||
{ inlineSize: 10, blockSize: 5 },
|
||||
{ inlineSize: 20, blockSize: 7 },
|
||||
],
|
||||
contentRect: { width: 0, height: 0 },
|
||||
} as unknown as ResizeObserverEntry,
|
||||
], {} as ResizeObserver);
|
||||
|
||||
expect(size!.width.value).toBe(30);
|
||||
expect(size!.height.value).toBe(12);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reads borderBoxSize when box is "border-box"', () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let size: ReturnType<typeof useElementSize>;
|
||||
scope.run(() => {
|
||||
size = useElementSize(ref(el), { width: 0, height: 0 }, { box: 'border-box' });
|
||||
});
|
||||
|
||||
instances[0]!.cb([
|
||||
{
|
||||
borderBoxSize: [{ inlineSize: 200, blockSize: 120 }],
|
||||
contentBoxSize: [{ inlineSize: 1, blockSize: 1 }],
|
||||
contentRect: { width: 0, height: 0 },
|
||||
} as unknown as ResizeObserverEntry,
|
||||
], {} as ResizeObserver);
|
||||
|
||||
expect(size!.width.value).toBe(200);
|
||||
expect(size!.height.value).toBe(120);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('measures SVG elements via getBoundingClientRect', () => {
|
||||
const el = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
el.getBoundingClientRect = () => ({ width: 64, height: 48 }) as DOMRect;
|
||||
const scope = effectScope();
|
||||
let size: ReturnType<typeof useElementSize>;
|
||||
scope.run(() => {
|
||||
size = useElementSize(ref(el), { width: 0, height: 0 }, { window: globalThis as unknown as Window });
|
||||
});
|
||||
|
||||
// Even though the entry advertises a different box size, the SVG path wins.
|
||||
instances[0]!.cb([
|
||||
{ contentBoxSize: [{ inlineSize: 999, blockSize: 999 }], contentRect: { width: 999, height: 999 } } as unknown as ResizeObserverEntry,
|
||||
], {} as ResizeObserver);
|
||||
|
||||
expect(size!.width.value).toBe(64);
|
||||
expect(size!.height.value).toBe(48);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('resets to 0 when the element detaches', async () => {
|
||||
const el = ref<HTMLElement | undefined>(document.createElement('div'));
|
||||
const scope = effectScope();
|
||||
let size: ReturnType<typeof useElementSize>;
|
||||
scope.run(() => {
|
||||
size = useElementSize(el, { width: 5, height: 7 });
|
||||
});
|
||||
|
||||
fire(100, 50);
|
||||
expect(size!.width.value).toBe(100);
|
||||
|
||||
el.value = undefined;
|
||||
await nextTick();
|
||||
|
||||
expect(size!.width.value).toBe(0);
|
||||
expect(size!.height.value).toBe(0);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('stop() disconnects the observer and the detach watcher', async () => {
|
||||
const el = ref<HTMLElement | undefined>(document.createElement('div'));
|
||||
const scope = effectScope();
|
||||
let size: ReturnType<typeof useElementSize>;
|
||||
scope.run(() => {
|
||||
size = useElementSize(el, { width: 0, height: 0 });
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
expect(instances[0]!.disconnect).not.toHaveBeenCalled();
|
||||
|
||||
fire(100, 50);
|
||||
expect(size!.width.value).toBe(100);
|
||||
|
||||
size!.stop();
|
||||
// The observer is torn down so it stops delivering callbacks in a real browser.
|
||||
expect(instances[0]!.disconnect).toHaveBeenCalled();
|
||||
|
||||
// The detach watcher is also stopped: clearing the target no longer resets the size to 0.
|
||||
el.value = undefined;
|
||||
await nextTick();
|
||||
expect(size!.width.value).toBe(100);
|
||||
expect(size!.height.value).toBe(50);
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
import { computed, shallowRef, watch } from 'vue';
|
||||
import type { ShallowRef } from 'vue';
|
||||
import { toArray } from '@robonen/stdlib';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
||||
import { unrefElement } from '@/composables/component/unrefElement';
|
||||
import { useResizeObserver } from '@/composables/browser/useResizeObserver';
|
||||
import type { UseResizeObserverOptions } from '@/composables/browser/useResizeObserver';
|
||||
import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted';
|
||||
|
||||
export interface ElementSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface UseElementSizeOptions extends UseResizeObserverOptions, ConfigurableWindow {}
|
||||
|
||||
export interface UseElementSizeReturn {
|
||||
width: ShallowRef<number>;
|
||||
height: ShallowRef<number>;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useElementSize
|
||||
* @category Browser
|
||||
* @description Reactive size of an element, backed by `ResizeObserver`.
|
||||
* Measures synchronously on mount, handles SVG elements via `getBoundingClientRect`,
|
||||
* and sums multiple box fragments (e.g. multi-column layouts).
|
||||
*
|
||||
* @param {MaybeComputedElementRef} target Element to measure (ref, getter, or component instance)
|
||||
* @param {ElementSize} [initialSize={ width: 0, height: 0 }] Initial size, restored when the element detaches
|
||||
* @param {UseElementSizeOptions} [options={}] Options forwarded to `ResizeObserver` (`box`, `window`)
|
||||
* @returns {UseElementSizeReturn} Reactive `width`, `height`, and a `stop` handle
|
||||
*
|
||||
* @example
|
||||
* const el = useTemplateRef('el');
|
||||
* const { width, height } = useElementSize(el);
|
||||
*
|
||||
* @example
|
||||
* const { width, height, stop } = useElementSize(el, { width: 100, height: 100 }, { box: 'border-box' });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useElementSize(
|
||||
target: MaybeComputedElementRef,
|
||||
initialSize: ElementSize = { width: 0, height: 0 },
|
||||
options: UseElementSizeOptions = {},
|
||||
): UseElementSizeReturn {
|
||||
const { window = defaultWindow, box = 'content-box' } = options;
|
||||
|
||||
const width = shallowRef(initialSize.width);
|
||||
const height = shallowRef(initialSize.height);
|
||||
|
||||
const isSVG = computed(() => unrefElement(target)?.namespaceURI?.includes('svg'));
|
||||
|
||||
const { stop: stopObserver } = useResizeObserver(target, ([entry]) => {
|
||||
if (!entry)
|
||||
return;
|
||||
|
||||
// SVG elements report unreliable box sizes in some browsers; measure the layout box instead.
|
||||
if (window && isSVG.value) {
|
||||
const el = unrefElement(target);
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
width.value = rect.width;
|
||||
height.value = rect.height;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const boxSize = box === 'border-box'
|
||||
? entry.borderBoxSize
|
||||
: box === 'content-box'
|
||||
? entry.contentBoxSize
|
||||
: entry.devicePixelContentBoxSize;
|
||||
|
||||
if (boxSize) {
|
||||
// Normalise the cross-browser `ResizeObserverSize | ReadonlyArray<ResizeObserverSize>` shape
|
||||
// and sum fragments (e.g. multi-column layouts) in a single pass.
|
||||
let nextWidth = 0;
|
||||
let nextHeight = 0;
|
||||
for (const size of toArray(boxSize as ResizeObserverSize | ResizeObserverSize[])) {
|
||||
nextWidth += size.inlineSize;
|
||||
nextHeight += size.blockSize;
|
||||
}
|
||||
width.value = nextWidth;
|
||||
height.value = nextHeight;
|
||||
}
|
||||
else {
|
||||
width.value = entry.contentRect.width;
|
||||
height.value = entry.contentRect.height;
|
||||
}
|
||||
}, options);
|
||||
|
||||
// Provide a measurement immediately on mount, before the first observer callback fires.
|
||||
tryOnMounted(() => {
|
||||
const el = unrefElement(target);
|
||||
if (el) {
|
||||
width.value = 'offsetWidth' in el ? (el as HTMLElement).offsetWidth : initialSize.width;
|
||||
height.value = 'offsetHeight' in el ? (el as HTMLElement).offsetHeight : initialSize.height;
|
||||
}
|
||||
});
|
||||
|
||||
// Reset to the initial size when the element is attached/detached.
|
||||
const stopWatch = watch(
|
||||
() => unrefElement(target),
|
||||
(el) => {
|
||||
width.value = el ? initialSize.width : 0;
|
||||
height.value = el ? initialSize.height : 0;
|
||||
},
|
||||
);
|
||||
|
||||
const stop = (): void => {
|
||||
stopObserver();
|
||||
stopWatch();
|
||||
};
|
||||
|
||||
return { width, height, stop };
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, isReadonly, ref } from 'vue';
|
||||
import type { UseElementVisibilityReturn } from '.';
|
||||
import { useElementVisibility } from '.';
|
||||
|
||||
let instances: StubIntersectionObserver[] = [];
|
||||
let lastInit: IntersectionObserverInit | undefined;
|
||||
|
||||
class StubIntersectionObserver {
|
||||
observe = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
unobserve = vi.fn();
|
||||
takeRecords = vi.fn();
|
||||
cb: IntersectionObserverCallback;
|
||||
init?: IntersectionObserverInit;
|
||||
constructor(cb: IntersectionObserverCallback, init?: IntersectionObserverInit) {
|
||||
this.cb = cb;
|
||||
this.init = init;
|
||||
lastInit = init;
|
||||
instances.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
describe(useElementVisibility, () => {
|
||||
beforeEach(() => {
|
||||
instances = [];
|
||||
lastInit = undefined;
|
||||
vi.stubGlobal('IntersectionObserver', StubIntersectionObserver);
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('is false initially and updates on intersection', () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let isVisible: UseElementVisibilityReturn<false>;
|
||||
scope.run(() => {
|
||||
isVisible = useElementVisibility(ref(el));
|
||||
});
|
||||
|
||||
expect(isVisible!.value).toBeFalsy();
|
||||
|
||||
instances[0]!.cb([{ isIntersecting: true, time: 1 } as IntersectionObserverEntry], {} as IntersectionObserver);
|
||||
expect(isVisible!.value).toBeTruthy();
|
||||
|
||||
instances[0]!.cb([{ isIntersecting: false, time: 2 } as IntersectionObserverEntry], {} as IntersectionObserver);
|
||||
expect(isVisible!.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('uses the most recent entry by time', () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let isVisible: UseElementVisibilityReturn<false>;
|
||||
scope.run(() => {
|
||||
isVisible = useElementVisibility(ref(el));
|
||||
});
|
||||
|
||||
instances[0]!.cb([
|
||||
{ isIntersecting: false, time: 5 } as IntersectionObserverEntry,
|
||||
{ isIntersecting: true, time: 10 } as IntersectionObserverEntry,
|
||||
], {} as IntersectionObserver);
|
||||
expect(isVisible!.value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('respects initialValue', () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let isVisible: UseElementVisibilityReturn<false>;
|
||||
scope.run(() => {
|
||||
isVisible = useElementVisibility(ref(el), { initialValue: true });
|
||||
});
|
||||
|
||||
expect(isVisible!.value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('returns a writable shallow ref (not readonly) by default', () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let isVisible: UseElementVisibilityReturn<false>;
|
||||
scope.run(() => {
|
||||
isVisible = useElementVisibility(ref(el));
|
||||
});
|
||||
|
||||
expect(isReadonly(isVisible!)).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('forwards rootMargin and threshold to the observer', () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
scope.run(() => useElementVisibility(ref(el), { rootMargin: '10px', threshold: [0, 0.5, 1] }));
|
||||
|
||||
expect(lastInit?.rootMargin).toBe('10px');
|
||||
expect(lastInit?.threshold).toEqual([0, 0.5, 1]);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('stops observing after first visibility when once is true', () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let isVisible: UseElementVisibilityReturn<false>;
|
||||
scope.run(() => {
|
||||
isVisible = useElementVisibility(ref(el), { once: true });
|
||||
});
|
||||
|
||||
const observer = instances[0]!;
|
||||
|
||||
// Not visible yet: should not disconnect.
|
||||
observer.cb([{ isIntersecting: false, time: 1 } as IntersectionObserverEntry], {} as IntersectionObserver);
|
||||
expect(observer.disconnect).not.toHaveBeenCalled();
|
||||
expect(isVisible!.value).toBeFalsy();
|
||||
|
||||
// Becomes visible: stop() should disconnect the observer.
|
||||
observer.cb([{ isIntersecting: true, time: 2 } as IntersectionObserverEntry], {} as IntersectionObserver);
|
||||
expect(isVisible!.value).toBeTruthy();
|
||||
expect(observer.disconnect).toHaveBeenCalled();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('exposes observer controls when controls is true', () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let result: UseElementVisibilityReturn<true>;
|
||||
scope.run(() => {
|
||||
result = useElementVisibility(ref(el), { controls: true });
|
||||
});
|
||||
|
||||
expect(result!).toHaveProperty('isVisible');
|
||||
expect(result!).toHaveProperty('stop');
|
||||
expect(result!).toHaveProperty('pause');
|
||||
expect(result!).toHaveProperty('resume');
|
||||
expect(result!).toHaveProperty('isSupported');
|
||||
expect(result!).toHaveProperty('isActive');
|
||||
|
||||
expect(result!.isVisible.value).toBeFalsy();
|
||||
instances[0]!.cb([{ isIntersecting: true, time: 1 } as IntersectionObserverEntry], {} as IntersectionObserver);
|
||||
expect(result!.isVisible.value).toBeTruthy();
|
||||
|
||||
result!.stop();
|
||||
expect(instances[0]!.disconnect).toHaveBeenCalled();
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -1,108 +0,0 @@
|
||||
import { shallowRef } from 'vue';
|
||||
import type { ShallowRef } from 'vue';
|
||||
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
||||
import { useIntersectionObserver } from '@/composables/browser/useIntersectionObserver';
|
||||
import type { UseIntersectionObserverOptions, UseIntersectionObserverReturn } from '@/composables/browser/useIntersectionObserver';
|
||||
|
||||
export interface UseElementVisibilityOptions<Controls extends boolean = false> extends UseIntersectionObserverOptions {
|
||||
/**
|
||||
* The initial visibility state, used before the observer reports its first entry.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
initialValue?: boolean;
|
||||
|
||||
/**
|
||||
* Stop observing as soon as the element becomes visible for the first time.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
once?: boolean;
|
||||
|
||||
/**
|
||||
* Expose the underlying observer controls (`pause`, `resume`, `stop`, ...)
|
||||
* alongside the visibility ref instead of returning the ref directly.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
controls?: Controls;
|
||||
}
|
||||
|
||||
export interface UseElementVisibilityReturnWithControls extends UseIntersectionObserverReturn {
|
||||
/**
|
||||
* Whether the element is currently visible within the root/viewport.
|
||||
*/
|
||||
isVisible: ShallowRef<boolean>;
|
||||
}
|
||||
|
||||
export type UseElementVisibilityReturn<Controls extends boolean = false>
|
||||
= Controls extends true
|
||||
? UseElementVisibilityReturnWithControls
|
||||
: ShallowRef<boolean>;
|
||||
|
||||
/**
|
||||
* @name useElementVisibility
|
||||
* @category Browser
|
||||
* @description Track whether an element is visible within the viewport (or a
|
||||
* custom scroll root), backed by `IntersectionObserver`.
|
||||
*
|
||||
* @param {MaybeComputedElementRef} target Element to track
|
||||
* @param {UseElementVisibilityOptions} [options={}] Options
|
||||
* @returns {UseElementVisibilityReturn} Visibility ref, or `{ isVisible, ...controls }` when `controls` is `true`
|
||||
*
|
||||
* @example
|
||||
* const isVisible = useElementVisibility(el);
|
||||
*
|
||||
* @example
|
||||
* const { isVisible, stop } = useElementVisibility(el, { controls: true, once: true });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useElementVisibility(
|
||||
target: MaybeComputedElementRef,
|
||||
options?: UseElementVisibilityOptions<false>,
|
||||
): UseElementVisibilityReturn<false>;
|
||||
export function useElementVisibility(
|
||||
target: MaybeComputedElementRef,
|
||||
options: UseElementVisibilityOptions<true>,
|
||||
): UseElementVisibilityReturn<true>;
|
||||
export function useElementVisibility(
|
||||
target: MaybeComputedElementRef,
|
||||
options: UseElementVisibilityOptions<boolean> = {},
|
||||
): UseElementVisibilityReturn<boolean> {
|
||||
const {
|
||||
initialValue = false,
|
||||
once = false,
|
||||
controls = false,
|
||||
...observerOptions
|
||||
} = options;
|
||||
|
||||
const isVisible = shallowRef(initialValue);
|
||||
|
||||
const observer = useIntersectionObserver(target, (entries) => {
|
||||
// Use the most recent entry to reflect the latest state.
|
||||
let latest = isVisible.value;
|
||||
let latestTime = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.time >= latestTime) {
|
||||
latestTime = entry.time;
|
||||
latest = entry.isIntersecting;
|
||||
}
|
||||
}
|
||||
|
||||
isVisible.value = latest;
|
||||
|
||||
if (once && latest)
|
||||
observer.stop();
|
||||
}, observerOptions);
|
||||
|
||||
if (controls) {
|
||||
return {
|
||||
...observer,
|
||||
isVisible,
|
||||
};
|
||||
}
|
||||
|
||||
return isVisible;
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useEscapeKey } from '.';
|
||||
|
||||
function dispatchEscape() {
|
||||
globalThis.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
}
|
||||
|
||||
describe(useEscapeKey, () => {
|
||||
afterEach(() => {
|
||||
// Ensure no lingering subscribers between tests
|
||||
});
|
||||
|
||||
it('fires handler on Escape', () => {
|
||||
const h = vi.fn();
|
||||
const stop = useEscapeKey(h);
|
||||
|
||||
dispatchEscape();
|
||||
expect(h).toHaveBeenCalledTimes(1);
|
||||
|
||||
stop();
|
||||
});
|
||||
|
||||
it('ignores non-Escape keys', () => {
|
||||
const h = vi.fn();
|
||||
const stop = useEscapeKey(h);
|
||||
|
||||
globalThis.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
|
||||
expect(h).not.toHaveBeenCalled();
|
||||
|
||||
stop();
|
||||
});
|
||||
|
||||
it('only topmost handler fires when multiple are stacked', () => {
|
||||
const bottom = vi.fn();
|
||||
const top = vi.fn();
|
||||
|
||||
const stopBottom = useEscapeKey(bottom);
|
||||
const stopTop = useEscapeKey(top);
|
||||
|
||||
dispatchEscape();
|
||||
expect(top).toHaveBeenCalledTimes(1);
|
||||
expect(bottom).not.toHaveBeenCalled();
|
||||
|
||||
stopTop();
|
||||
dispatchEscape();
|
||||
expect(bottom).toHaveBeenCalledTimes(1);
|
||||
|
||||
stopBottom();
|
||||
});
|
||||
|
||||
it('stop handle unsubscribes the listener', () => {
|
||||
const h = vi.fn();
|
||||
const stop = useEscapeKey(h);
|
||||
stop();
|
||||
|
||||
dispatchEscape();
|
||||
expect(h).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { VoidFunction } from '@robonen/stdlib';
|
||||
import { noop } from '@robonen/stdlib';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
import { defaultWindow } from '@/types';
|
||||
|
||||
type EscapeListener = (event: KeyboardEvent) => void;
|
||||
|
||||
// Module-scoped stack: only the topmost non-paused layer handles Escape so that
|
||||
// nested dismissables behave correctly (top-most dialog closes first).
|
||||
const stack: EscapeListener[] = [];
|
||||
let installed = false;
|
||||
let cleanup: VoidFunction = noop;
|
||||
|
||||
function install() {
|
||||
if (installed || !defaultWindow) return;
|
||||
installed = true;
|
||||
|
||||
cleanup = useEventListener(defaultWindow, 'keydown', (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
|
||||
const top = stack.at(-1);
|
||||
top?.(event);
|
||||
}, { capture: true });
|
||||
}
|
||||
|
||||
function uninstall() {
|
||||
if (!installed || stack.length > 0) return;
|
||||
installed = false;
|
||||
cleanup();
|
||||
cleanup = noop;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useEscapeKey
|
||||
* @category Browser
|
||||
* @description Register a callback for the topmost Escape keydown. Uses an internal
|
||||
* stack so that nested layers (e.g. nested Dialogs) dismiss in the correct order —
|
||||
* only the most recently-registered listener fires for a given keydown.
|
||||
*
|
||||
* @param {(event: KeyboardEvent) => void} handler Callback invoked on the topmost Escape
|
||||
* @returns {VoidFunction} Stop handle that removes the subscription
|
||||
*
|
||||
* @since 0.0.14
|
||||
*/
|
||||
export function useEscapeKey(handler: EscapeListener): VoidFunction {
|
||||
if (!defaultWindow) return noop;
|
||||
|
||||
install();
|
||||
stack.push(handler);
|
||||
|
||||
const stop = () => {
|
||||
const i = stack.lastIndexOf(handler);
|
||||
if (i !== -1) stack.splice(i, 1);
|
||||
uninstall();
|
||||
};
|
||||
|
||||
tryOnScopeDispose(stop);
|
||||
return stop;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { shallowRef } from 'vue';
|
||||
import type { ComputedRef, ShallowRef } from 'vue';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { useSupported } from '@/composables/browser/useSupported';
|
||||
import { useSupported } from '@/composables/utilities/useSupported';
|
||||
|
||||
export interface EyeDropperOpenOptions {
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick, ref } from 'vue';
|
||||
import type { FileSystemFileHandle, UseFileSystemAccessReturn } from '.';
|
||||
import { useFileSystemAccess } from '.';
|
||||
|
||||
interface WritableSpy {
|
||||
write: ReturnType<typeof vi.fn>;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function makeFile(contents = 'hello world', name = 'demo.txt', type = 'text/plain'): File {
|
||||
const file = {
|
||||
name,
|
||||
type,
|
||||
size: contents.length,
|
||||
lastModified: 1234,
|
||||
text: vi.fn(async () => contents),
|
||||
arrayBuffer: vi.fn(async () => new ArrayBuffer(contents.length)),
|
||||
};
|
||||
return file as unknown as File;
|
||||
}
|
||||
|
||||
function makeHandle(file: File): { handle: FileSystemFileHandle; writable: WritableSpy } {
|
||||
const writable: WritableSpy = {
|
||||
write: vi.fn(async () => {}),
|
||||
close: vi.fn(async () => {}),
|
||||
};
|
||||
const handle = {
|
||||
getFile: vi.fn(async () => file),
|
||||
createWritable: vi.fn(async () => writable),
|
||||
} as unknown as FileSystemFileHandle;
|
||||
return { handle, writable };
|
||||
}
|
||||
|
||||
function stubWindow(handle?: FileSystemFileHandle) {
|
||||
const showOpenFilePicker = vi.fn(async () => (handle ? [handle] : []));
|
||||
const showSaveFilePicker = vi.fn(async () => handle);
|
||||
const window = {
|
||||
showOpenFilePicker,
|
||||
showSaveFilePicker,
|
||||
} as unknown as Window;
|
||||
return { window, showOpenFilePicker, showSaveFilePicker };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe(useFileSystemAccess, () => {
|
||||
it('reports support when the picker APIs exist', () => {
|
||||
const { window } = stubWindow();
|
||||
const scope = effectScope();
|
||||
let fsa: UseFileSystemAccessReturn;
|
||||
scope.run(() => {
|
||||
fsa = useFileSystemAccess({ window });
|
||||
});
|
||||
expect(fsa!.isSupported.value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('is not supported without the picker APIs', () => {
|
||||
const window = {} as unknown as Window;
|
||||
const scope = effectScope();
|
||||
let fsa: UseFileSystemAccessReturn;
|
||||
scope.run(() => {
|
||||
fsa = useFileSystemAccess({ window });
|
||||
});
|
||||
expect(fsa!.isSupported.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('is not supported and is a no-op under SSR (no window)', async () => {
|
||||
const scope = effectScope();
|
||||
let fsa: UseFileSystemAccessReturn;
|
||||
scope.run(() => {
|
||||
fsa = useFileSystemAccess({ window: undefined });
|
||||
});
|
||||
expect(fsa!.isSupported.value).toBeFalsy();
|
||||
await expect(fsa!.open()).resolves.toBeUndefined();
|
||||
await expect(fsa!.save()).resolves.toBeUndefined();
|
||||
expect(fsa!.fileName.value).toBe('');
|
||||
expect(fsa!.fileSize.value).toBe(0);
|
||||
expect(fsa!.data.value).toBeUndefined();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('opens a file and reads it as text by default', async () => {
|
||||
const file = makeFile('content');
|
||||
const { handle } = makeHandle(file);
|
||||
const { window, showOpenFilePicker } = stubWindow(handle);
|
||||
const scope = effectScope();
|
||||
let fsa: UseFileSystemAccessReturn<string>;
|
||||
scope.run(() => {
|
||||
fsa = useFileSystemAccess({ window, dataType: 'Text' });
|
||||
});
|
||||
|
||||
await fsa!.open();
|
||||
expect(showOpenFilePicker).toHaveBeenCalledOnce();
|
||||
expect(fsa!.data.value).toBe('content');
|
||||
expect(fsa!.file.value).toBe(file);
|
||||
expect(fsa!.fileName.value).toBe('demo.txt');
|
||||
expect(fsa!.fileMIME.value).toBe('text/plain');
|
||||
expect(fsa!.fileSize.value).toBe(7);
|
||||
expect(fsa!.fileLastModified.value).toBe(1234);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reads as ArrayBuffer when dataType is ArrayBuffer', async () => {
|
||||
const file = makeFile('abc');
|
||||
const { handle } = makeHandle(file);
|
||||
const { window } = stubWindow(handle);
|
||||
const scope = effectScope();
|
||||
let fsa: UseFileSystemAccessReturn<ArrayBuffer>;
|
||||
scope.run(() => {
|
||||
fsa = useFileSystemAccess({ window, dataType: 'ArrayBuffer' });
|
||||
});
|
||||
|
||||
await fsa!.open();
|
||||
expect(fsa!.data.value).toBeInstanceOf(ArrayBuffer);
|
||||
expect(file.arrayBuffer).toHaveBeenCalled();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('exposes the File itself when dataType is Blob', async () => {
|
||||
const file = makeFile('abc');
|
||||
const { handle } = makeHandle(file);
|
||||
const { window } = stubWindow(handle);
|
||||
const scope = effectScope();
|
||||
let fsa: UseFileSystemAccessReturn<Blob>;
|
||||
scope.run(() => {
|
||||
fsa = useFileSystemAccess({ window, dataType: 'Blob' });
|
||||
});
|
||||
|
||||
await fsa!.open();
|
||||
expect(fsa!.data.value).toBe(file);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('passes types and excludeAcceptAllOption to the open picker', async () => {
|
||||
const file = makeFile();
|
||||
const { handle } = makeHandle(file);
|
||||
const { window, showOpenFilePicker } = stubWindow(handle);
|
||||
const types = [{ description: 'text', accept: { 'text/plain': ['.txt'] } }];
|
||||
const scope = effectScope();
|
||||
let fsa: UseFileSystemAccessReturn;
|
||||
scope.run(() => {
|
||||
fsa = useFileSystemAccess({ window, types, excludeAcceptAllOption: true });
|
||||
});
|
||||
|
||||
await fsa!.open();
|
||||
expect(showOpenFilePicker).toHaveBeenCalledWith({ types, excludeAcceptAllOption: true });
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('lets per-call open options override the defaults', async () => {
|
||||
const file = makeFile();
|
||||
const { handle } = makeHandle(file);
|
||||
const { window, showOpenFilePicker } = stubWindow(handle);
|
||||
const scope = effectScope();
|
||||
let fsa: UseFileSystemAccessReturn;
|
||||
scope.run(() => {
|
||||
fsa = useFileSystemAccess({ window, excludeAcceptAllOption: false });
|
||||
});
|
||||
|
||||
await fsa!.open({ excludeAcceptAllOption: true });
|
||||
expect(showOpenFilePicker).toHaveBeenCalledWith({ types: undefined, excludeAcceptAllOption: true });
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('creates a new empty handle and clears prior data', async () => {
|
||||
const file = makeFile('');
|
||||
const { handle } = makeHandle(file);
|
||||
const { window, showSaveFilePicker } = stubWindow(handle);
|
||||
const scope = effectScope();
|
||||
let fsa: UseFileSystemAccessReturn;
|
||||
scope.run(() => {
|
||||
fsa = useFileSystemAccess({ window });
|
||||
});
|
||||
|
||||
await fsa!.create({ suggestedName: 'new.txt' });
|
||||
expect(showSaveFilePicker).toHaveBeenCalledWith({ types: undefined, excludeAcceptAllOption: undefined, suggestedName: 'new.txt' });
|
||||
expect(fsa!.file.value).toBe(file);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('save writes current data to the existing handle', async () => {
|
||||
const file = makeFile('original');
|
||||
const { handle, writable } = makeHandle(file);
|
||||
const { window } = stubWindow(handle);
|
||||
const scope = effectScope();
|
||||
let fsa: UseFileSystemAccessReturn<string>;
|
||||
scope.run(() => {
|
||||
fsa = useFileSystemAccess({ window, dataType: 'Text' });
|
||||
});
|
||||
|
||||
await fsa!.open();
|
||||
fsa!.data.value = 'edited';
|
||||
await fsa!.save();
|
||||
|
||||
expect(writable.write).toHaveBeenCalledWith('edited');
|
||||
expect(writable.close).toHaveBeenCalledOnce();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('save falls back to saveAs when there is no handle', async () => {
|
||||
const file = makeFile('');
|
||||
const { handle, writable } = makeHandle(file);
|
||||
const { window, showSaveFilePicker, showOpenFilePicker } = stubWindow(handle);
|
||||
const scope = effectScope();
|
||||
let fsa: UseFileSystemAccessReturn<string>;
|
||||
scope.run(() => {
|
||||
fsa = useFileSystemAccess({ window, dataType: 'Text' });
|
||||
});
|
||||
|
||||
fsa!.data.value = 'fresh';
|
||||
await fsa!.save();
|
||||
|
||||
expect(showOpenFilePicker).not.toHaveBeenCalled();
|
||||
expect(showSaveFilePicker).toHaveBeenCalledOnce();
|
||||
expect(writable.write).toHaveBeenCalledWith('fresh');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('saveAs requests a new handle and writes data', async () => {
|
||||
const file = makeFile('');
|
||||
const { handle, writable } = makeHandle(file);
|
||||
const { window, showSaveFilePicker } = stubWindow(handle);
|
||||
const scope = effectScope();
|
||||
let fsa: UseFileSystemAccessReturn<string>;
|
||||
scope.run(() => {
|
||||
fsa = useFileSystemAccess({ window, dataType: 'Text' });
|
||||
});
|
||||
|
||||
fsa!.data.value = 'payload';
|
||||
await fsa!.saveAs({ suggestedName: 'out.txt' });
|
||||
|
||||
expect(showSaveFilePicker).toHaveBeenCalledWith({ types: undefined, excludeAcceptAllOption: undefined, suggestedName: 'out.txt' });
|
||||
expect(writable.write).toHaveBeenCalledWith('payload');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not write when there is no data', async () => {
|
||||
const file = makeFile('');
|
||||
const { handle, writable } = makeHandle(file);
|
||||
const { window } = stubWindow(handle);
|
||||
const scope = effectScope();
|
||||
let fsa: UseFileSystemAccessReturn<string>;
|
||||
scope.run(() => {
|
||||
fsa = useFileSystemAccess({ window, dataType: 'Text' });
|
||||
});
|
||||
|
||||
await fsa!.saveAs();
|
||||
expect(writable.write).not.toHaveBeenCalled();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('updateData re-reads the current file', async () => {
|
||||
const file = makeFile('v1');
|
||||
const { handle } = makeHandle(file);
|
||||
const { window } = stubWindow(handle);
|
||||
const scope = effectScope();
|
||||
let fsa: UseFileSystemAccessReturn<string>;
|
||||
scope.run(() => {
|
||||
fsa = useFileSystemAccess({ window, dataType: 'Text' });
|
||||
});
|
||||
|
||||
await fsa!.open();
|
||||
expect(fsa!.data.value).toBe('v1');
|
||||
|
||||
(file.text as ReturnType<typeof vi.fn>).mockResolvedValueOnce('v2');
|
||||
await fsa!.updateData();
|
||||
expect(fsa!.data.value).toBe('v2');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('re-reads data when a reactive dataType changes', async () => {
|
||||
const file = makeFile('reactive');
|
||||
const { handle } = makeHandle(file);
|
||||
const { window } = stubWindow(handle);
|
||||
const dataType = ref<'Text' | 'Blob'>('Text');
|
||||
const scope = effectScope();
|
||||
let fsa: UseFileSystemAccessReturn;
|
||||
scope.run(() => {
|
||||
fsa = useFileSystemAccess({ window, dataType });
|
||||
});
|
||||
|
||||
await fsa!.open();
|
||||
expect(fsa!.data.value).toBe('reactive');
|
||||
|
||||
dataType.value = 'Blob';
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
expect(fsa!.data.value).toBe(file);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('routes picker errors to onError instead of throwing', async () => {
|
||||
const abort = new DOMException('cancelled', 'AbortError');
|
||||
const window = {
|
||||
showOpenFilePicker: vi.fn(async () => { throw abort; }),
|
||||
showSaveFilePicker: vi.fn(async () => { throw abort; }),
|
||||
} as unknown as Window;
|
||||
const onError = vi.fn();
|
||||
const scope = effectScope();
|
||||
let fsa: UseFileSystemAccessReturn;
|
||||
scope.run(() => {
|
||||
fsa = useFileSystemAccess({ window, onError });
|
||||
});
|
||||
|
||||
await expect(fsa!.open()).resolves.toBeUndefined();
|
||||
expect(onError).toHaveBeenCalledWith(abort);
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,332 @@
|
||||
import { computed, shallowRef, toValue, watch } from 'vue';
|
||||
import type { ComputedRef, MaybeRefOrGetter, ShallowRef } from 'vue';
|
||||
import { noop } from '@robonen/stdlib';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { useSupported } from '@/composables/utilities/useSupported';
|
||||
|
||||
/**
|
||||
* `window.showOpenFilePicker` parameters.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/Window/showOpenFilePicker#parameters
|
||||
*/
|
||||
export interface FileSystemAccessShowOpenFileOptions {
|
||||
multiple?: boolean;
|
||||
types?: Array<{
|
||||
description?: string;
|
||||
accept: Record<string, string[]>;
|
||||
}>;
|
||||
excludeAcceptAllOption?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* `window.showSaveFilePicker` parameters.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/Window/showSaveFilePicker#parameters
|
||||
*/
|
||||
export interface FileSystemAccessShowSaveFileOptions {
|
||||
suggestedName?: string;
|
||||
types?: Array<{
|
||||
description?: string;
|
||||
accept: Record<string, string[]>;
|
||||
}>;
|
||||
excludeAcceptAllOption?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/FileSystemWritableFileStream/write
|
||||
*/
|
||||
export interface FileSystemWritableFileStreamWrite {
|
||||
(data: string | BufferSource | Blob): Promise<void>;
|
||||
(options: { type: 'write'; position: number; data: string | BufferSource | Blob }): Promise<void>;
|
||||
(options: { type: 'seek'; position: number }): Promise<void>;
|
||||
(options: { type: 'truncate'; size: number }): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/FileSystemWritableFileStream
|
||||
*/
|
||||
export interface FileSystemWritableFileStream extends WritableStream {
|
||||
write: FileSystemWritableFileStreamWrite;
|
||||
seek: (position: number) => Promise<void>;
|
||||
truncate: (size: number) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/FileSystemFileHandle
|
||||
*/
|
||||
export interface FileSystemFileHandle {
|
||||
getFile: () => Promise<File>;
|
||||
createWritable: () => Promise<FileSystemWritableFileStream>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A `window` augmented with the File System Access API entry points.
|
||||
*/
|
||||
export type FileSystemAccessWindow
|
||||
= Window & {
|
||||
showSaveFilePicker: (options: FileSystemAccessShowSaveFileOptions) => Promise<FileSystemFileHandle>;
|
||||
showOpenFilePicker: (options: FileSystemAccessShowOpenFileOptions) => Promise<FileSystemFileHandle[]>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The supported file data types.
|
||||
*/
|
||||
export type UseFileSystemAccessDataType = 'Text' | 'ArrayBuffer' | 'Blob';
|
||||
|
||||
/**
|
||||
* Picker options shared between open/create/save operations.
|
||||
*/
|
||||
export type UseFileSystemAccessCommonOptions
|
||||
= Pick<FileSystemAccessShowOpenFileOptions, 'types' | 'excludeAcceptAllOption'>;
|
||||
|
||||
/**
|
||||
* Picker options accepted by save-style operations.
|
||||
*/
|
||||
export type UseFileSystemAccessShowSaveFileOptions
|
||||
= Pick<FileSystemAccessShowSaveFileOptions, 'suggestedName'> & UseFileSystemAccessCommonOptions;
|
||||
|
||||
export type UseFileSystemAccessOptions
|
||||
= ConfigurableWindow & UseFileSystemAccessCommonOptions & {
|
||||
/**
|
||||
* How the file contents are read into `data`.
|
||||
*
|
||||
* @default 'Text'
|
||||
*/
|
||||
dataType?: MaybeRefOrGetter<UseFileSystemAccessDataType>;
|
||||
|
||||
/**
|
||||
* Called when a picker or file operation fails.
|
||||
*
|
||||
* User-cancelled pickers reject with an `AbortError`; route them here to
|
||||
* keep them out of the global unhandled-rejection channel.
|
||||
*
|
||||
* @default noop
|
||||
*/
|
||||
onError?: (error: unknown) => void;
|
||||
};
|
||||
|
||||
export interface UseFileSystemAccessReturn<T = string | ArrayBuffer | Blob> {
|
||||
/**
|
||||
* Whether the File System Access API is available.
|
||||
*/
|
||||
isSupported: ComputedRef<boolean>;
|
||||
|
||||
/**
|
||||
* The current file contents, read according to `dataType`.
|
||||
*/
|
||||
data: ShallowRef<T | undefined>;
|
||||
|
||||
/**
|
||||
* The currently bound `File`, or `undefined` when no file is open.
|
||||
*/
|
||||
file: ShallowRef<File | undefined>;
|
||||
|
||||
/**
|
||||
* The current file name (empty string when no file is open).
|
||||
*/
|
||||
fileName: ComputedRef<string>;
|
||||
|
||||
/**
|
||||
* The current file MIME type (empty string when no file is open).
|
||||
*/
|
||||
fileMIME: ComputedRef<string>;
|
||||
|
||||
/**
|
||||
* The current file size in bytes (`0` when no file is open).
|
||||
*/
|
||||
fileSize: ComputedRef<number>;
|
||||
|
||||
/**
|
||||
* The current file's last-modified timestamp (`0` when no file is open).
|
||||
*/
|
||||
fileLastModified: ComputedRef<number>;
|
||||
|
||||
/**
|
||||
* Show the open-file picker and load the chosen file.
|
||||
*/
|
||||
open: (options?: UseFileSystemAccessCommonOptions) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Show the save-file picker to create a new, empty file handle.
|
||||
*/
|
||||
create: (options?: UseFileSystemAccessShowSaveFileOptions) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Write `data` back to the current handle (falls back to `saveAs` when none).
|
||||
*/
|
||||
save: (options?: UseFileSystemAccessShowSaveFileOptions) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Show the save-file picker, then write `data` to the chosen handle.
|
||||
*/
|
||||
saveAs: (options?: UseFileSystemAccessShowSaveFileOptions) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Re-read `data` (and metadata) from the current handle.
|
||||
*/
|
||||
updateData: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useFileSystemAccess
|
||||
* @category Browser
|
||||
* @description Create, read, and write local files via the File System Access API.
|
||||
*
|
||||
* @param {UseFileSystemAccessOptions} [options={}] Options including `dataType`, `types`, `excludeAcceptAllOption`, and `onError`
|
||||
* @returns {UseFileSystemAccessReturn} `isSupported`, `data`, `file`, `fileName`, `fileMIME`, `fileSize`, `fileLastModified`, `open`, `create`, `save`, `saveAs`, `updateData`
|
||||
*
|
||||
* @example
|
||||
* const { isSupported, data, open, save } = useFileSystemAccess({ dataType: 'Text' });
|
||||
* await open();
|
||||
* data.value += '\nappended';
|
||||
* await save();
|
||||
*
|
||||
* @example
|
||||
* // Read raw bytes
|
||||
* const { data } = useFileSystemAccess({ dataType: 'ArrayBuffer' });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useFileSystemAccess(): UseFileSystemAccessReturn<string | ArrayBuffer | Blob>;
|
||||
export function useFileSystemAccess(options: UseFileSystemAccessOptions & { dataType: 'Text' }): UseFileSystemAccessReturn<string>;
|
||||
export function useFileSystemAccess(options: UseFileSystemAccessOptions & { dataType: 'ArrayBuffer' }): UseFileSystemAccessReturn<ArrayBuffer>;
|
||||
export function useFileSystemAccess(options: UseFileSystemAccessOptions & { dataType: 'Blob' }): UseFileSystemAccessReturn<Blob>;
|
||||
export function useFileSystemAccess(options: UseFileSystemAccessOptions): UseFileSystemAccessReturn<string | ArrayBuffer | Blob>;
|
||||
export function useFileSystemAccess(
|
||||
options: UseFileSystemAccessOptions = {},
|
||||
): UseFileSystemAccessReturn<string | ArrayBuffer | Blob> {
|
||||
const {
|
||||
window: win = defaultWindow,
|
||||
dataType = 'Text',
|
||||
types,
|
||||
excludeAcceptAllOption,
|
||||
onError = noop,
|
||||
} = options;
|
||||
|
||||
const fsWindow = win as FileSystemAccessWindow | undefined;
|
||||
const isSupported = useSupported(() => fsWindow && 'showSaveFilePicker' in fsWindow && 'showOpenFilePicker' in fsWindow);
|
||||
|
||||
const fileHandle = shallowRef<FileSystemFileHandle>();
|
||||
const data = shallowRef<string | ArrayBuffer | Blob>();
|
||||
const file = shallowRef<File>();
|
||||
|
||||
const fileName = computed(() => file.value?.name ?? '');
|
||||
const fileMIME = computed(() => file.value?.type ?? '');
|
||||
const fileSize = computed(() => file.value?.size ?? 0);
|
||||
const fileLastModified = computed(() => file.value?.lastModified ?? 0);
|
||||
|
||||
// Resolve the picker defaults once per call rather than spreading the full
|
||||
// options bag (which carries `window`/`onError`) into the native picker.
|
||||
function pickerDefaults(): UseFileSystemAccessCommonOptions {
|
||||
return { types, excludeAcceptAllOption };
|
||||
}
|
||||
|
||||
async function updateFile(): Promise<void> {
|
||||
file.value = await fileHandle.value?.getFile();
|
||||
}
|
||||
|
||||
async function updateData(): Promise<void> {
|
||||
await updateFile();
|
||||
|
||||
const type = toValue(dataType);
|
||||
|
||||
if (type === 'Text')
|
||||
data.value = await file.value?.text();
|
||||
else if (type === 'ArrayBuffer')
|
||||
data.value = await file.value?.arrayBuffer();
|
||||
else if (type === 'Blob')
|
||||
data.value = file.value;
|
||||
}
|
||||
|
||||
async function writeData(): Promise<void> {
|
||||
if (!fileHandle.value || data.value === undefined || data.value === null)
|
||||
return;
|
||||
|
||||
const stream = await fileHandle.value.createWritable();
|
||||
await stream.write(data.value);
|
||||
await stream.close();
|
||||
}
|
||||
|
||||
async function open(_options: UseFileSystemAccessCommonOptions = {}): Promise<void> {
|
||||
if (!isSupported.value || !fsWindow)
|
||||
return;
|
||||
|
||||
try {
|
||||
const [handle] = await fsWindow.showOpenFilePicker({ ...pickerDefaults(), ..._options });
|
||||
if (!handle)
|
||||
return;
|
||||
fileHandle.value = handle;
|
||||
await updateData();
|
||||
}
|
||||
catch (error) {
|
||||
onError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function create(_options: UseFileSystemAccessShowSaveFileOptions = {}): Promise<void> {
|
||||
if (!isSupported.value || !fsWindow)
|
||||
return;
|
||||
|
||||
try {
|
||||
fileHandle.value = await fsWindow.showSaveFilePicker({ ...pickerDefaults(), ..._options });
|
||||
data.value = undefined;
|
||||
await updateData();
|
||||
}
|
||||
catch (error) {
|
||||
onError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAs(_options: UseFileSystemAccessShowSaveFileOptions = {}): Promise<void> {
|
||||
if (!isSupported.value || !fsWindow)
|
||||
return;
|
||||
|
||||
try {
|
||||
fileHandle.value = await fsWindow.showSaveFilePicker({ ...pickerDefaults(), ..._options });
|
||||
await writeData();
|
||||
await updateFile();
|
||||
}
|
||||
catch (error) {
|
||||
onError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function save(_options: UseFileSystemAccessShowSaveFileOptions = {}): Promise<void> {
|
||||
if (!isSupported.value)
|
||||
return;
|
||||
|
||||
if (!fileHandle.value)
|
||||
return saveAs(_options);
|
||||
|
||||
try {
|
||||
await writeData();
|
||||
await updateFile();
|
||||
}
|
||||
catch (error) {
|
||||
onError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-read with the new strategy whenever `dataType` changes; skip the redundant
|
||||
// initial run since nothing is open yet.
|
||||
watch(() => toValue(dataType), () => {
|
||||
if (fileHandle.value)
|
||||
void updateData();
|
||||
});
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
data,
|
||||
file,
|
||||
fileName,
|
||||
fileMIME,
|
||||
fileSize,
|
||||
fileLastModified,
|
||||
open,
|
||||
create,
|
||||
save,
|
||||
saveAs,
|
||||
updateData,
|
||||
};
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { effectScope, nextTick, ref } from 'vue';
|
||||
import { useFocus } from '.';
|
||||
|
||||
function host<T>(fn: () => T): { result: T; stop: () => void } {
|
||||
const scope = effectScope();
|
||||
let result!: T;
|
||||
scope.run(() => {
|
||||
result = fn();
|
||||
});
|
||||
return { result, stop: () => scope.stop() };
|
||||
}
|
||||
|
||||
describe(useFocus, () => {
|
||||
it('reflects focus and blur events on the target', async () => {
|
||||
const input = document.createElement('input');
|
||||
document.body.appendChild(input);
|
||||
|
||||
const { result, stop } = host(() => useFocus(input));
|
||||
await nextTick();
|
||||
|
||||
expect(result.focused.value).toBeFalsy();
|
||||
|
||||
input.dispatchEvent(new FocusEvent('focus'));
|
||||
expect(result.focused.value).toBeTruthy();
|
||||
|
||||
input.dispatchEvent(new FocusEvent('blur'));
|
||||
expect(result.focused.value).toBeFalsy();
|
||||
|
||||
stop();
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it('focuses the element when writing true', async () => {
|
||||
const input = document.createElement('input');
|
||||
document.body.appendChild(input);
|
||||
|
||||
const { result, stop } = host(() => useFocus(input));
|
||||
await nextTick();
|
||||
|
||||
result.focused.value = true;
|
||||
// jsdom dispatches the focus event synchronously from .focus()
|
||||
expect(document.activeElement).toBe(input);
|
||||
expect(result.focused.value).toBeTruthy();
|
||||
|
||||
stop();
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it('blurs the element when writing false', async () => {
|
||||
const input = document.createElement('input');
|
||||
document.body.appendChild(input);
|
||||
|
||||
const { result, stop } = host(() => useFocus(input));
|
||||
await nextTick();
|
||||
|
||||
result.focused.value = true;
|
||||
expect(document.activeElement).toBe(input);
|
||||
|
||||
result.focused.value = false;
|
||||
expect(document.activeElement).not.toBe(input);
|
||||
expect(result.focused.value).toBeFalsy();
|
||||
|
||||
stop();
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it('focuses on mount when initialValue is true', async () => {
|
||||
const input = document.createElement('input');
|
||||
document.body.appendChild(input);
|
||||
|
||||
const { result, stop } = host(() => useFocus(input, { initialValue: true }));
|
||||
// the watch runs with flush: 'post'
|
||||
await nextTick();
|
||||
|
||||
expect(document.activeElement).toBe(input);
|
||||
expect(result.focused.value).toBeTruthy();
|
||||
|
||||
stop();
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it('passes preventScroll to focus()', async () => {
|
||||
const input = document.createElement('input');
|
||||
document.body.appendChild(input);
|
||||
let receivedOptions: FocusOptions | undefined;
|
||||
const originalFocus = input.focus.bind(input);
|
||||
input.focus = (opts?: FocusOptions) => {
|
||||
receivedOptions = opts;
|
||||
originalFocus(opts);
|
||||
};
|
||||
|
||||
const { result, stop } = host(() => useFocus(input, { preventScroll: true }));
|
||||
await nextTick();
|
||||
|
||||
result.focused.value = true;
|
||||
expect(receivedOptions).toEqual({ preventScroll: true });
|
||||
|
||||
stop();
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it('respects focusVisible by checking :focus-visible', async () => {
|
||||
const input = document.createElement('input');
|
||||
document.body.appendChild(input);
|
||||
// emulate the element NOT matching :focus-visible (e.g. mouse focus)
|
||||
input.matches = () => false;
|
||||
|
||||
const { result, stop } = host(() => useFocus(input, { focusVisible: true }));
|
||||
await nextTick();
|
||||
|
||||
input.dispatchEvent(new FocusEvent('focus'));
|
||||
// focus should be ignored because :focus-visible did not match
|
||||
expect(result.focused.value).toBeFalsy();
|
||||
|
||||
// now emulate a keyboard focus that does match
|
||||
input.matches = (selector: string) => selector === ':focus-visible';
|
||||
input.dispatchEvent(new FocusEvent('focus'));
|
||||
expect(result.focused.value).toBeTruthy();
|
||||
|
||||
stop();
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it('tracks a reactive (changing) target', async () => {
|
||||
const a = document.createElement('input');
|
||||
const b = document.createElement('input');
|
||||
document.body.append(a, b);
|
||||
|
||||
const target = ref<HTMLElement>(a);
|
||||
const { result, stop } = host(() => useFocus(target));
|
||||
await nextTick();
|
||||
|
||||
// start unfocused, then focus the first target
|
||||
a.dispatchEvent(new FocusEvent('focus'));
|
||||
expect(result.focused.value).toBeTruthy();
|
||||
a.dispatchEvent(new FocusEvent('blur'));
|
||||
expect(result.focused.value).toBeFalsy();
|
||||
|
||||
// swap the tracked target; listeners follow the reactive ref
|
||||
target.value = b;
|
||||
await nextTick();
|
||||
|
||||
// events on the new element are now tracked
|
||||
b.dispatchEvent(new FocusEvent('focus'));
|
||||
expect(result.focused.value).toBeTruthy();
|
||||
b.dispatchEvent(new FocusEvent('blur'));
|
||||
expect(result.focused.value).toBeFalsy();
|
||||
|
||||
// events on the old element no longer affect state
|
||||
a.dispatchEvent(new FocusEvent('focus'));
|
||||
expect(result.focused.value).toBeFalsy();
|
||||
|
||||
stop();
|
||||
a.remove();
|
||||
b.remove();
|
||||
});
|
||||
|
||||
it('does not throw when the target is null', async () => {
|
||||
const target = ref<HTMLElement | null>(null);
|
||||
const { result, stop } = host(() => useFocus(target));
|
||||
await nextTick();
|
||||
|
||||
expect(result.focused.value).toBeFalsy();
|
||||
// writing to a null target must be a no-op, not a crash
|
||||
expect(() => {
|
||||
result.focused.value = true;
|
||||
}).not.toThrow();
|
||||
expect(result.focused.value).toBeFalsy();
|
||||
|
||||
stop();
|
||||
});
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
import { computed, shallowRef, watch } from 'vue';
|
||||
import type { WritableComputedRef } from 'vue';
|
||||
import { unrefElement } from '@/composables/component/unrefElement';
|
||||
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
|
||||
export interface UseFocusOptions extends ConfigurableWindow {
|
||||
/**
|
||||
* Initial focus state. When `true`, the element is focused on mount.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
initialValue?: boolean;
|
||||
/**
|
||||
* Only consider the element focused when it matches `:focus-visible`,
|
||||
* mirroring the browser's keyboard-focus heuristics.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
focusVisible?: boolean;
|
||||
/**
|
||||
* Prevent the browser from scrolling the element into view when focusing it.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
preventScroll?: boolean;
|
||||
}
|
||||
|
||||
export interface UseFocusReturn {
|
||||
/**
|
||||
* Reactive focus state. Read it to know whether the target is focused, or
|
||||
* write to it to programmatically focus (`true`) or blur (`false`) the target.
|
||||
*/
|
||||
focused: WritableComputedRef<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useFocus
|
||||
* @category Browser
|
||||
* @description Reactive focus state of an element. The returned `focused` ref tracks
|
||||
* focus/blur events and can be written to in order to focus or blur the target.
|
||||
*
|
||||
* @param {MaybeComputedElementRef} target - The element (or template ref) to track.
|
||||
* @param {UseFocusOptions} [options={}] - Options
|
||||
* @returns {UseFocusReturn} An object containing the writable `focused` ref.
|
||||
*
|
||||
* @example
|
||||
* const el = useTemplateRef<HTMLInputElement>('el');
|
||||
* const { focused } = useFocus(el);
|
||||
* // focus the element imperatively
|
||||
* focused.value = true;
|
||||
*
|
||||
* @example
|
||||
* // only treat keyboard focus as focused
|
||||
* const { focused } = useFocus(el, { focusVisible: true });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useFocus(
|
||||
target: MaybeComputedElementRef,
|
||||
options: UseFocusOptions = {},
|
||||
): UseFocusReturn {
|
||||
const {
|
||||
initialValue = false,
|
||||
focusVisible = false,
|
||||
preventScroll = false,
|
||||
} = options;
|
||||
|
||||
const innerFocused = shallowRef(false);
|
||||
const targetElement = computed(() => unrefElement(target) as HTMLElement | undefined | null);
|
||||
|
||||
const listenerOptions = { passive: true } as const;
|
||||
|
||||
useEventListener(
|
||||
targetElement,
|
||||
'focus',
|
||||
(event: FocusEvent) => {
|
||||
if (!focusVisible || (event.target as HTMLElement).matches?.(':focus-visible'))
|
||||
innerFocused.value = true;
|
||||
},
|
||||
listenerOptions,
|
||||
);
|
||||
|
||||
useEventListener(
|
||||
targetElement,
|
||||
'blur',
|
||||
() => {
|
||||
innerFocused.value = false;
|
||||
},
|
||||
listenerOptions,
|
||||
);
|
||||
|
||||
const focused = computed<boolean>({
|
||||
get: () => innerFocused.value,
|
||||
set(value: boolean) {
|
||||
if (!value && innerFocused.value)
|
||||
targetElement.value?.blur();
|
||||
else if (value && !innerFocused.value)
|
||||
targetElement.value?.focus({ preventScroll });
|
||||
},
|
||||
});
|
||||
|
||||
watch(
|
||||
targetElement,
|
||||
() => {
|
||||
focused.value = initialValue;
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
);
|
||||
|
||||
return { focused };
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { defineComponent, nextTick } from 'vue';
|
||||
import { useFocusGuard } from '.';
|
||||
|
||||
const setupFocusGuard = (namespace?: string) => {
|
||||
return mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
useFocusGuard(namespace);
|
||||
},
|
||||
template: '<div></div>',
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const getFocusGuards = (namespace: string) =>
|
||||
document.querySelectorAll(`[data-${namespace}]`);
|
||||
|
||||
describe(useFocusGuard, () => {
|
||||
let component: ReturnType<typeof setupFocusGuard>;
|
||||
const namespace = 'test-guard';
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
component.unmount();
|
||||
});
|
||||
|
||||
it('create focus guards when mounted', async () => {
|
||||
component = setupFocusGuard(namespace);
|
||||
|
||||
const guards = getFocusGuards(namespace);
|
||||
expect(guards).toHaveLength(2);
|
||||
|
||||
guards.forEach((guard) => {
|
||||
expect(guard.getAttribute('tabindex')).toBe('0');
|
||||
expect(guard.getAttribute('style')).toContain('opacity: 0');
|
||||
});
|
||||
});
|
||||
|
||||
it('remove focus guards when unmounted', () => {
|
||||
component = setupFocusGuard(namespace);
|
||||
|
||||
component.unmount();
|
||||
|
||||
expect(getFocusGuards(namespace)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('correctly manage multiple instances with the same namespace', () => {
|
||||
const wrapper1 = setupFocusGuard(namespace);
|
||||
const wrapper2 = setupFocusGuard(namespace);
|
||||
|
||||
// Guards should not be duplicated
|
||||
expect(getFocusGuards(namespace)).toHaveLength(2);
|
||||
|
||||
wrapper1.unmount();
|
||||
|
||||
// Second instance still keeps the guards
|
||||
expect(getFocusGuards(namespace)).toHaveLength(2);
|
||||
|
||||
wrapper2.unmount();
|
||||
|
||||
// No guards left after all instances are unmounted
|
||||
expect(getFocusGuards(namespace)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import { focusGuard } from '@robonen/platform/browsers';
|
||||
import { onMounted, onUnmounted } from 'vue';
|
||||
|
||||
// Global counter to drop the focus guards when the last instance is unmounted
|
||||
let counter = 0;
|
||||
|
||||
/**
|
||||
* @name useFocusGuard
|
||||
* @category Browser
|
||||
* @description Adds a pair of focus guards at the boundaries of the DOM tree to ensure consistent focus behavior
|
||||
*
|
||||
* @param {string} [namespace] - A namespace to group the focus guards
|
||||
* @returns {void}
|
||||
*
|
||||
* @example
|
||||
* useFocusGuard();
|
||||
*
|
||||
* @example
|
||||
* useFocusGuard('my-namespace');
|
||||
*
|
||||
* @since 0.0.2
|
||||
*/
|
||||
export function useFocusGuard(namespace?: string) {
|
||||
const manager = focusGuard(namespace);
|
||||
|
||||
const createGuard = () => {
|
||||
manager.createGuard();
|
||||
counter++;
|
||||
};
|
||||
|
||||
const removeGuard = () => {
|
||||
if (counter <= 1)
|
||||
manager.removeGuard();
|
||||
|
||||
counter = Math.max(0, counter - 1);
|
||||
};
|
||||
|
||||
onMounted(createGuard);
|
||||
onUnmounted(removeGuard);
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { effectScope, nextTick, shallowRef } from 'vue';
|
||||
import { useFocusWithin } from '.';
|
||||
|
||||
function makeTree(): { container: HTMLDivElement; input: HTMLInputElement; outside: HTMLButtonElement } {
|
||||
const container = document.createElement('div');
|
||||
const input = document.createElement('input');
|
||||
container.appendChild(input);
|
||||
|
||||
const outside = document.createElement('button');
|
||||
|
||||
document.body.appendChild(container);
|
||||
document.body.appendChild(outside);
|
||||
|
||||
return { container, input, outside };
|
||||
}
|
||||
|
||||
describe(useFocusWithin, () => {
|
||||
it('is not focused initially when nothing inside has focus', () => {
|
||||
const { container } = makeTree();
|
||||
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useFocusWithin>;
|
||||
scope.run(() => {
|
||||
result = useFocusWithin(container);
|
||||
});
|
||||
|
||||
expect(result!.focused.value).toBeFalsy();
|
||||
|
||||
scope.stop();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it('becomes focused when a descendant receives focus', async () => {
|
||||
const { container, input } = makeTree();
|
||||
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useFocusWithin>;
|
||||
scope.run(() => {
|
||||
result = useFocusWithin(container);
|
||||
});
|
||||
|
||||
input.focus();
|
||||
container.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||
await nextTick();
|
||||
|
||||
expect(result!.focused.value).toBeTruthy();
|
||||
|
||||
scope.stop();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it('is focused when the target element itself receives focus', async () => {
|
||||
const { container } = makeTree();
|
||||
container.tabIndex = 0;
|
||||
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useFocusWithin>;
|
||||
scope.run(() => {
|
||||
result = useFocusWithin(container);
|
||||
});
|
||||
|
||||
container.focus();
|
||||
container.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||
await nextTick();
|
||||
|
||||
expect(result!.focused.value).toBeTruthy();
|
||||
|
||||
scope.stop();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it('clears focus when focus leaves the element entirely', async () => {
|
||||
const { container, input, outside } = makeTree();
|
||||
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useFocusWithin>;
|
||||
scope.run(() => {
|
||||
result = useFocusWithin(container);
|
||||
});
|
||||
|
||||
input.focus();
|
||||
container.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||
await nextTick();
|
||||
expect(result!.focused.value).toBeTruthy();
|
||||
|
||||
// Move focus to an element outside the container.
|
||||
outside.focus();
|
||||
container.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: outside }));
|
||||
await nextTick();
|
||||
|
||||
expect(result!.focused.value).toBeFalsy();
|
||||
|
||||
scope.stop();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it('stays focused when focus moves between descendants', async () => {
|
||||
const { container, input } = makeTree();
|
||||
const second = document.createElement('input');
|
||||
container.appendChild(second);
|
||||
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useFocusWithin>;
|
||||
scope.run(() => {
|
||||
result = useFocusWithin(container);
|
||||
});
|
||||
|
||||
input.focus();
|
||||
container.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||
await nextTick();
|
||||
expect(result!.focused.value).toBeTruthy();
|
||||
|
||||
// focusout fires as focus shifts, but the second input is still inside
|
||||
// the container, so `:focus-within` keeps the state true.
|
||||
second.focus();
|
||||
container.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: second }));
|
||||
await nextTick();
|
||||
|
||||
expect(result!.focused.value).toBeTruthy();
|
||||
|
||||
scope.stop();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it('reflects focus that already lives inside the target on creation', () => {
|
||||
const { container, input } = makeTree();
|
||||
input.focus();
|
||||
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useFocusWithin>;
|
||||
scope.run(() => {
|
||||
result = useFocusWithin(container);
|
||||
});
|
||||
|
||||
expect(result!.focused.value).toBeTruthy();
|
||||
|
||||
scope.stop();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it('accepts a reactive ref target', async () => {
|
||||
const { container, input } = makeTree();
|
||||
const targetRef = shallowRef<HTMLElement | null>(container);
|
||||
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useFocusWithin>;
|
||||
scope.run(() => {
|
||||
result = useFocusWithin(targetRef);
|
||||
});
|
||||
|
||||
input.focus();
|
||||
container.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||
await nextTick();
|
||||
|
||||
expect(result!.focused.value).toBeTruthy();
|
||||
|
||||
scope.stop();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it('exposes a read-only computed (write throws / has no setter)', () => {
|
||||
const { container } = makeTree();
|
||||
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useFocusWithin>;
|
||||
scope.run(() => {
|
||||
result = useFocusWithin(container);
|
||||
});
|
||||
|
||||
// computed without a setter ignores writes (does not mutate internal state)
|
||||
// @ts-expect-error - intentionally writing to a read-only computed
|
||||
result!.focused.value = true;
|
||||
expect(result!.focused.value).toBeFalsy();
|
||||
|
||||
scope.stop();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it('does not throw and stays false when window is unavailable (SSR)', () => {
|
||||
const { container } = makeTree();
|
||||
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useFocusWithin>;
|
||||
scope.run(() => {
|
||||
result = useFocusWithin(container, { window: undefined });
|
||||
});
|
||||
|
||||
expect(result!.focused.value).toBeFalsy();
|
||||
|
||||
scope.stop();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import { computed, shallowRef } from 'vue';
|
||||
import type { ComputedRef } from 'vue';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { unrefElement } from '@/composables/component/unrefElement';
|
||||
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
|
||||
export interface UseFocusWithinOptions extends ConfigurableWindow {}
|
||||
|
||||
export interface UseFocusWithinReturn {
|
||||
/**
|
||||
* Whether the element or any of its descendants currently hold focus.
|
||||
*/
|
||||
focused: ComputedRef<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useFocusWithin
|
||||
* @category Browser
|
||||
* @description Reactive tracking of whether an element or any of its
|
||||
* descendants are focused, backed by the `focusin`/`focusout` events.
|
||||
*
|
||||
* @param {MaybeComputedElementRef} target Element to track
|
||||
* @param {UseFocusWithinOptions} [options={}] Options
|
||||
* @returns {UseFocusWithinReturn} `{ focused }` reactive focus-within state
|
||||
*
|
||||
* @example
|
||||
* const el = useTemplateRef<HTMLElement>('el');
|
||||
* const { focused } = useFocusWithin(el);
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useFocusWithin(
|
||||
target: MaybeComputedElementRef,
|
||||
options: UseFocusWithinOptions = {},
|
||||
): UseFocusWithinReturn {
|
||||
const { window = defaultWindow } = options;
|
||||
|
||||
const _focused = shallowRef(false);
|
||||
const focused = computed(() => _focused.value);
|
||||
|
||||
const activeElement = window?.document?.activeElement;
|
||||
|
||||
const targetElement = computed(() => unrefElement(target) as HTMLElement | undefined | null);
|
||||
|
||||
if (window) {
|
||||
useEventListener(targetElement, 'focusin', () => {
|
||||
_focused.value = true;
|
||||
}, { passive: true });
|
||||
|
||||
useEventListener(targetElement, 'focusout', (event: FocusEvent) => {
|
||||
// After focus leaves a descendant, confirm focus did not simply move to
|
||||
// another descendant. `event.relatedTarget` carries the element about to
|
||||
// receive focus; if it is still inside `target` we remain focused. We
|
||||
// also consult the `:focus-within` pseudo-class as a fallback for cases
|
||||
// where `relatedTarget` is unavailable.
|
||||
const el = unrefElement(target);
|
||||
|
||||
if (!el) {
|
||||
_focused.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const next = event.relatedTarget as Node | null;
|
||||
if (next && el.contains(next)) {
|
||||
_focused.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_focused.value = el.matches?.(':focus-within') ?? false;
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
// Reflect focus that already lives inside the target on initialization.
|
||||
const el = unrefElement(target);
|
||||
if (el && activeElement && (el === activeElement || el.contains(activeElement)))
|
||||
_focused.value = true;
|
||||
|
||||
return {
|
||||
focused,
|
||||
};
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope } from 'vue';
|
||||
import { useFps } from '.';
|
||||
|
||||
let rafCallbacks: Array<(time: number) => void> = [];
|
||||
let rafIdCounter = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
rafCallbacks = [];
|
||||
rafIdCounter = 0;
|
||||
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: (time: number) => void) => {
|
||||
const id = ++rafIdCounter;
|
||||
rafCallbacks.push(cb);
|
||||
return id;
|
||||
});
|
||||
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function triggerFrame(time: number) {
|
||||
const cbs = [...rafCallbacks];
|
||||
rafCallbacks = [];
|
||||
cbs.forEach(cb => cb(time));
|
||||
}
|
||||
|
||||
function triggerFrames(startTime: number, interval: number, count: number) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
triggerFrame(startTime + i * interval);
|
||||
}
|
||||
}
|
||||
|
||||
describe(useFps, () => {
|
||||
it('starts at 0 fps', () => {
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useFps>;
|
||||
|
||||
scope.run(() => {
|
||||
result = useFps();
|
||||
});
|
||||
|
||||
expect(result!.fps.value).toBe(0);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reports fps after "every" frames', () => {
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useFps>;
|
||||
|
||||
scope.run(() => {
|
||||
result = useFps({ every: 5 });
|
||||
});
|
||||
|
||||
// ~60fps = 16.67ms per frame
|
||||
// First frame has delta=0, skipped by useFps. Need 5 real-delta frames.
|
||||
triggerFrame(100); // delta=0, skipped
|
||||
triggerFrame(116.67); // delta=16.67
|
||||
triggerFrame(133.33); // delta=16.66
|
||||
triggerFrame(150); // delta=16.67
|
||||
triggerFrame(166.67); // delta=16.67
|
||||
triggerFrame(183.33); // delta=16.66 → 5 deltas collected, update
|
||||
|
||||
expect(result!.fps.value).toBe(60);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not update fps before collecting enough frames', () => {
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useFps>;
|
||||
|
||||
scope.run(() => {
|
||||
result = useFps({ every: 10 });
|
||||
});
|
||||
|
||||
triggerFrame(100);
|
||||
triggerFrame(116.67);
|
||||
triggerFrame(133.33);
|
||||
|
||||
expect(result!.fps.value).toBe(0);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('tracks min and max fps', () => {
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useFps>;
|
||||
|
||||
scope.run(() => {
|
||||
result = useFps({ every: 3 });
|
||||
});
|
||||
|
||||
// First batch: ~60fps (16.67ms intervals)
|
||||
triggerFrame(100); // delta=0, skipped
|
||||
triggerFrame(116.67); // delta=16.67
|
||||
triggerFrame(133.33); // delta=16.66
|
||||
triggerFrame(150); // delta=16.67 → 3 deltas, update
|
||||
|
||||
const firstFps = result!.fps.value;
|
||||
expect(firstFps).toBe(60);
|
||||
|
||||
// Second batch: ~30fps (33.33ms intervals)
|
||||
triggerFrame(183.33); // delta=33.33
|
||||
triggerFrame(216.67); // delta=33.34
|
||||
triggerFrame(250); // delta=33.33 → 3 deltas, update
|
||||
|
||||
const secondFps = result!.fps.value;
|
||||
expect(secondFps).toBe(30);
|
||||
|
||||
expect(result!.max.value).toBe(60);
|
||||
expect(result!.min.value).toBe(30);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('resets min, max, and fps', () => {
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useFps>;
|
||||
|
||||
scope.run(() => {
|
||||
result = useFps({ every: 3 });
|
||||
});
|
||||
|
||||
triggerFrame(100);
|
||||
triggerFrame(116.67);
|
||||
triggerFrame(133.33);
|
||||
triggerFrame(150);
|
||||
|
||||
expect(result!.fps.value).toBe(60);
|
||||
|
||||
result!.reset();
|
||||
|
||||
expect(result!.fps.value).toBe(0);
|
||||
expect(result!.min.value).toBe(Infinity);
|
||||
expect(result!.max.value).toBe(0);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('cleans up on scope dispose', () => {
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
useFps();
|
||||
});
|
||||
|
||||
// Should not throw on stop
|
||||
scope.stop();
|
||||
|
||||
// No more raf callbacks should be registered after stop
|
||||
triggerFrame(100);
|
||||
expect(rafCallbacks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does nothing when window is undefined (SSR)', () => {
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useFps>;
|
||||
|
||||
scope.run(() => {
|
||||
result = useFps({ window: undefined as any });
|
||||
});
|
||||
|
||||
expect(result!.fps.value).toBe(0);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('is active by default', () => {
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useFps>;
|
||||
|
||||
scope.run(() => {
|
||||
result = useFps();
|
||||
});
|
||||
|
||||
expect(result!.isActive.value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not start when immediate is false', () => {
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useFps>;
|
||||
|
||||
scope.run(() => {
|
||||
result = useFps({ immediate: false });
|
||||
});
|
||||
|
||||
expect(result!.isActive.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('pauses and resumes fps tracking', () => {
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useFps>;
|
||||
|
||||
scope.run(() => {
|
||||
result = useFps({ every: 3 });
|
||||
});
|
||||
|
||||
// Collect one batch
|
||||
triggerFrame(100);
|
||||
triggerFrame(116.67);
|
||||
triggerFrame(133.33);
|
||||
triggerFrame(150);
|
||||
|
||||
expect(result!.fps.value).toBe(60);
|
||||
|
||||
result!.pause();
|
||||
expect(result!.isActive.value).toBeFalsy();
|
||||
|
||||
// Frames while paused should not update
|
||||
triggerFrame(200);
|
||||
triggerFrame(300);
|
||||
triggerFrame(400);
|
||||
triggerFrame(500);
|
||||
|
||||
expect(result!.fps.value).toBe(60);
|
||||
|
||||
result!.resume();
|
||||
expect(result!.isActive.value).toBeTruthy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('toggles fps tracking', () => {
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useFps>;
|
||||
|
||||
scope.run(() => {
|
||||
result = useFps();
|
||||
});
|
||||
|
||||
expect(result!.isActive.value).toBeTruthy();
|
||||
|
||||
result!.toggle();
|
||||
expect(result!.isActive.value).toBeFalsy();
|
||||
|
||||
result!.toggle();
|
||||
expect(result!.isActive.value).toBeTruthy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
import type { ConfigurableWindow, ResumableActions, ResumableOptions } from '@/types';
|
||||
import type { UseRafFnCallbackArgs } from '@/composables/browser/useRafFn';
|
||||
import type { Ref } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import { useRafFn } from '@/composables/browser/useRafFn';
|
||||
|
||||
export interface UseFpsOptions extends ResumableOptions, ConfigurableWindow {
|
||||
/**
|
||||
* Number of frames to average over for a smoother reading.
|
||||
*
|
||||
* @default 10
|
||||
*/
|
||||
every?: number;
|
||||
}
|
||||
|
||||
export interface UseFpsReturn extends ResumableActions {
|
||||
/**
|
||||
* Current frames per second (averaged over the last `every` frames)
|
||||
*/
|
||||
fps: Readonly<Ref<number>>;
|
||||
|
||||
/**
|
||||
* Minimum FPS recorded since the composable was created or last reset
|
||||
*/
|
||||
min: Readonly<Ref<number>>;
|
||||
|
||||
/**
|
||||
* Maximum FPS recorded since the composable was created or last reset
|
||||
*/
|
||||
max: Readonly<Ref<number>>;
|
||||
|
||||
/**
|
||||
* Whether the FPS counter is currently active
|
||||
*/
|
||||
isActive: Readonly<Ref<boolean>>;
|
||||
|
||||
/**
|
||||
* Reset min/max tracking
|
||||
*/
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive FPS counter based on `requestAnimationFrame`.
|
||||
* Reports a smoothed FPS value averaged over a configurable number of frames,
|
||||
* and tracks min/max values.
|
||||
*
|
||||
* @param options - Configuration options
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const { fps, min, max, reset } = useFps();
|
||||
* ```
|
||||
*/
|
||||
export function useFps(options: UseFpsOptions = {}): UseFpsReturn {
|
||||
const { every = 10, ...rafOptions } = options;
|
||||
|
||||
const fps = ref(0);
|
||||
const min = ref(Infinity);
|
||||
const max = ref(0);
|
||||
|
||||
let deltaSum = 0;
|
||||
let frameCount = 0;
|
||||
|
||||
function update({ delta }: UseRafFnCallbackArgs) {
|
||||
if (!delta)
|
||||
return;
|
||||
|
||||
deltaSum += delta;
|
||||
frameCount++;
|
||||
|
||||
if (frameCount < every)
|
||||
return;
|
||||
|
||||
const currentFps = Math.round(1000 / (deltaSum / frameCount));
|
||||
|
||||
fps.value = currentFps;
|
||||
|
||||
if (currentFps < min.value)
|
||||
min.value = currentFps;
|
||||
|
||||
if (currentFps > max.value)
|
||||
max.value = currentFps;
|
||||
|
||||
deltaSum = 0;
|
||||
frameCount = 0;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
min.value = Infinity;
|
||||
max.value = 0;
|
||||
fps.value = 0;
|
||||
deltaSum = 0;
|
||||
frameCount = 0;
|
||||
}
|
||||
|
||||
const { isActive, pause, resume, toggle } = useRafFn(update, rafOptions);
|
||||
|
||||
return {
|
||||
fps,
|
||||
min,
|
||||
max,
|
||||
isActive,
|
||||
reset,
|
||||
pause,
|
||||
resume,
|
||||
toggle,
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { defaultDocument } from '@/types';
|
||||
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
||||
import { unrefElement } from '@/composables/component/unrefElement';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
import { useSupported } from '@/composables/browser/useSupported';
|
||||
import { useSupported } from '@/composables/utilities/useSupported';
|
||||
import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick, shallowRef } from 'vue';
|
||||
import { useGeolocation } from '.';
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
function stubGeolocation() {
|
||||
let successCb: PositionCallback | null = null;
|
||||
let errorCb: PositionErrorCallback | null = null;
|
||||
let lastOptions: PositionOptions | undefined;
|
||||
const watchPosition = vi.fn((success: PositionCallback, err?: PositionErrorCallback | null, opts?: PositionOptions) => {
|
||||
successCb = success;
|
||||
errorCb = err ?? null;
|
||||
lastOptions = opts;
|
||||
return 1;
|
||||
});
|
||||
const clearWatch = vi.fn();
|
||||
const getCurrentPosition = vi.fn((success: PositionCallback, err?: PositionErrorCallback | null, opts?: PositionOptions) => {
|
||||
successCb = success;
|
||||
errorCb = err ?? null;
|
||||
lastOptions = opts;
|
||||
});
|
||||
const navigator = { geolocation: { watchPosition, clearWatch, getCurrentPosition } } as unknown as Navigator;
|
||||
return {
|
||||
navigator,
|
||||
watchPosition,
|
||||
clearWatch,
|
||||
getCurrentPosition,
|
||||
getOptions: () => lastOptions,
|
||||
emit: (position: GeolocationPosition) => successCb?.(position),
|
||||
emitError: (err: GeolocationPositionError) => errorCb?.(err),
|
||||
};
|
||||
}
|
||||
|
||||
function makePosition(latitude: number, longitude: number, timestamp = 123): GeolocationPosition {
|
||||
return {
|
||||
timestamp,
|
||||
coords: {
|
||||
latitude,
|
||||
longitude,
|
||||
accuracy: 5,
|
||||
altitude: null,
|
||||
altitudeAccuracy: null,
|
||||
heading: null,
|
||||
speed: null,
|
||||
} as GeolocationCoordinates,
|
||||
} as GeolocationPosition;
|
||||
}
|
||||
|
||||
function makeError(code = 1, message = 'denied'): GeolocationPositionError {
|
||||
return { code, message, PERMISSION_DENIED: 1, POSITION_UNAVAILABLE: 2, TIMEOUT: 3 } as GeolocationPositionError;
|
||||
}
|
||||
|
||||
describe(useGeolocation, () => {
|
||||
it('starts watching immediately by default', () => {
|
||||
const { watchPosition, navigator } = stubGeolocation();
|
||||
const scope = effectScope();
|
||||
scope.run(() => useGeolocation({ navigator }));
|
||||
expect(watchPosition).toHaveBeenCalled();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not start watching when immediate is false', () => {
|
||||
const { watchPosition, navigator } = stubGeolocation();
|
||||
const scope = effectScope();
|
||||
let geo: ReturnType<typeof useGeolocation>;
|
||||
scope.run(() => {
|
||||
geo = useGeolocation({ navigator, immediate: false });
|
||||
});
|
||||
expect(watchPosition).not.toHaveBeenCalled();
|
||||
expect(geo!.isActive.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('updates coords on position change', () => {
|
||||
const { emit, navigator } = stubGeolocation();
|
||||
const scope = effectScope();
|
||||
let geo: ReturnType<typeof useGeolocation>;
|
||||
scope.run(() => {
|
||||
geo = useGeolocation({ navigator });
|
||||
});
|
||||
|
||||
emit(makePosition(1, 2));
|
||||
|
||||
expect(geo!.coords.value.latitude).toBe(1);
|
||||
expect(geo!.coords.value.longitude).toBe(2);
|
||||
expect(geo!.locatedAt.value).toBe(123);
|
||||
expect(geo!.ready.value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('tracks active state across resume/pause', () => {
|
||||
const { navigator } = stubGeolocation();
|
||||
const scope = effectScope();
|
||||
let geo: ReturnType<typeof useGeolocation>;
|
||||
scope.run(() => {
|
||||
geo = useGeolocation({ navigator });
|
||||
});
|
||||
expect(geo!.isActive.value).toBeTruthy();
|
||||
geo!.pause();
|
||||
expect(geo!.isActive.value).toBeFalsy();
|
||||
geo!.resume();
|
||||
expect(geo!.isActive.value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('clears the watch on pause', () => {
|
||||
const { clearWatch, navigator } = stubGeolocation();
|
||||
const scope = effectScope();
|
||||
let geo: ReturnType<typeof useGeolocation>;
|
||||
scope.run(() => {
|
||||
geo = useGeolocation({ navigator });
|
||||
});
|
||||
geo!.pause();
|
||||
expect(clearWatch).toHaveBeenCalledWith(1);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not re-watch when already active', () => {
|
||||
const { watchPosition, navigator } = stubGeolocation();
|
||||
const scope = effectScope();
|
||||
let geo: ReturnType<typeof useGeolocation>;
|
||||
scope.run(() => {
|
||||
geo = useGeolocation({ navigator });
|
||||
});
|
||||
geo!.resume();
|
||||
geo!.resume();
|
||||
expect(watchPosition).toHaveBeenCalledTimes(1);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('records errors and invokes onError', () => {
|
||||
const { emitError, navigator } = stubGeolocation();
|
||||
const onError = vi.fn();
|
||||
const scope = effectScope();
|
||||
let geo: ReturnType<typeof useGeolocation>;
|
||||
scope.run(() => {
|
||||
geo = useGeolocation({ navigator, onError });
|
||||
});
|
||||
|
||||
const err = makeError();
|
||||
emitError(err);
|
||||
|
||||
expect(geo!.error.value).toBe(err);
|
||||
expect(onError).toHaveBeenCalledWith(err);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('clears the error after a successful fix', () => {
|
||||
const { emit, emitError, navigator } = stubGeolocation();
|
||||
const scope = effectScope();
|
||||
let geo: ReturnType<typeof useGeolocation>;
|
||||
scope.run(() => {
|
||||
geo = useGeolocation({ navigator });
|
||||
});
|
||||
|
||||
emitError(makeError());
|
||||
expect(geo!.error.value).not.toBeNull();
|
||||
emit(makePosition(1, 2));
|
||||
expect(geo!.error.value).toBeNull();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('passes position options to watchPosition', () => {
|
||||
const { getOptions, navigator } = stubGeolocation();
|
||||
const scope = effectScope();
|
||||
scope.run(() => useGeolocation({ navigator, enableHighAccuracy: false, maximumAge: 1000, timeout: 5000 }));
|
||||
expect(getOptions()).toEqual({ enableHighAccuracy: false, maximumAge: 1000, timeout: 5000 });
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('restarts the watcher when reactive options change while active', async () => {
|
||||
const { watchPosition, clearWatch, getOptions, navigator } = stubGeolocation();
|
||||
const highAccuracy = shallowRef(false);
|
||||
const scope = effectScope();
|
||||
scope.run(() => useGeolocation({ navigator, enableHighAccuracy: highAccuracy }));
|
||||
|
||||
expect(watchPosition).toHaveBeenCalledTimes(1);
|
||||
expect(getOptions()?.enableHighAccuracy).toBeFalsy();
|
||||
|
||||
highAccuracy.value = true;
|
||||
await nextTick();
|
||||
|
||||
expect(clearWatch).toHaveBeenCalledWith(1);
|
||||
expect(watchPosition).toHaveBeenCalledTimes(2);
|
||||
expect(getOptions()?.enableHighAccuracy).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not restart on option change when paused', async () => {
|
||||
const { watchPosition, navigator } = stubGeolocation();
|
||||
const timeout = shallowRef(1000);
|
||||
const scope = effectScope();
|
||||
let geo: ReturnType<typeof useGeolocation>;
|
||||
scope.run(() => {
|
||||
geo = useGeolocation({ navigator, timeout, immediate: false });
|
||||
});
|
||||
|
||||
timeout.value = 2000;
|
||||
await nextTick();
|
||||
|
||||
expect(watchPosition).not.toHaveBeenCalled();
|
||||
expect(geo!.isActive.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('getCurrentPosition resolves and updates state without a watch', async () => {
|
||||
const { watchPosition, getCurrentPosition, emit, navigator } = stubGeolocation();
|
||||
const scope = effectScope();
|
||||
let geo: ReturnType<typeof useGeolocation>;
|
||||
scope.run(() => {
|
||||
geo = useGeolocation({ navigator, immediate: false });
|
||||
});
|
||||
|
||||
const promise = geo!.getCurrentPosition();
|
||||
emit(makePosition(10, 20, 999));
|
||||
const position = await promise;
|
||||
|
||||
expect(getCurrentPosition).toHaveBeenCalled();
|
||||
expect(watchPosition).not.toHaveBeenCalled();
|
||||
expect(position.coords.latitude).toBe(10);
|
||||
expect(geo!.coords.value.latitude).toBe(10);
|
||||
expect(geo!.locatedAt.value).toBe(999);
|
||||
expect(geo!.ready.value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('getCurrentPosition rejects on error', async () => {
|
||||
const { emitError, navigator } = stubGeolocation();
|
||||
const scope = effectScope();
|
||||
let geo: ReturnType<typeof useGeolocation>;
|
||||
scope.run(() => {
|
||||
geo = useGeolocation({ navigator, immediate: false });
|
||||
});
|
||||
|
||||
const promise = geo!.getCurrentPosition();
|
||||
const err = makeError();
|
||||
emitError(err);
|
||||
|
||||
await expect(promise).rejects.toBe(err);
|
||||
expect(geo!.error.value).toBe(err);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reports unsupported when geolocation is missing', () => {
|
||||
const navigator = {} as Navigator;
|
||||
const scope = effectScope();
|
||||
let geo: ReturnType<typeof useGeolocation>;
|
||||
scope.run(() => {
|
||||
geo = useGeolocation({ navigator });
|
||||
});
|
||||
expect(geo!.isSupported.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('getCurrentPosition rejects when unsupported', async () => {
|
||||
const navigator = {} as Navigator;
|
||||
const scope = effectScope();
|
||||
let geo: ReturnType<typeof useGeolocation>;
|
||||
scope.run(() => {
|
||||
geo = useGeolocation({ navigator, immediate: false });
|
||||
});
|
||||
await expect(geo!.getCurrentPosition()).rejects.toThrow('not supported');
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -1,236 +0,0 @@
|
||||
import { shallowReadonly, shallowRef, toValue, watch } from 'vue';
|
||||
import type { MaybeRefOrGetter, Ref } from 'vue';
|
||||
import { noop } from '@robonen/stdlib';
|
||||
import { defaultNavigator } from '@/types';
|
||||
import type { ConfigurableNavigator } from '@/types';
|
||||
import { useSupported } from '@/composables/browser/useSupported';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
|
||||
export interface UseGeolocationOptions extends ConfigurableNavigator {
|
||||
/**
|
||||
* A boolean that indicates the application would like to receive the best
|
||||
* possible results. Reactive — changing it while watching restarts the watcher.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
enableHighAccuracy?: MaybeRefOrGetter<boolean>;
|
||||
|
||||
/**
|
||||
* The maximum age in milliseconds of a possible cached position that is
|
||||
* acceptable to return. Reactive — changing it while watching restarts the watcher.
|
||||
*
|
||||
* @default 30000
|
||||
*/
|
||||
maximumAge?: MaybeRefOrGetter<number>;
|
||||
|
||||
/**
|
||||
* The maximum length of time in milliseconds the device is allowed to take in
|
||||
* order to return a position. Reactive — changing it while watching restarts the watcher.
|
||||
*
|
||||
* @default 27000
|
||||
*/
|
||||
timeout?: MaybeRefOrGetter<number>;
|
||||
|
||||
/**
|
||||
* Start watching the position immediately
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
immediate?: boolean;
|
||||
|
||||
/**
|
||||
* Called whenever the Geolocation API reports an error. Receives the
|
||||
* `GeolocationPositionError`. Useful for reacting to permission denials or
|
||||
* timeouts without setting up a watcher on `error`.
|
||||
*
|
||||
* @default () => {}
|
||||
*/
|
||||
onError?: (error: GeolocationPositionError) => void;
|
||||
}
|
||||
|
||||
export interface UseGeolocationReturn {
|
||||
/**
|
||||
* Whether the Geolocation API is supported in the current environment.
|
||||
*/
|
||||
isSupported: Readonly<Ref<boolean>>;
|
||||
|
||||
/**
|
||||
* The most recent set of coordinates.
|
||||
*/
|
||||
coords: Readonly<Ref<Omit<GeolocationPosition['coords'], 'toJSON'>>>;
|
||||
|
||||
/**
|
||||
* The timestamp of the most recent position, or `null` before the first fix.
|
||||
*/
|
||||
locatedAt: Readonly<Ref<number | null>>;
|
||||
|
||||
/**
|
||||
* The most recent error, or `null` if none.
|
||||
*/
|
||||
error: Readonly<Ref<GeolocationPositionError | null>>;
|
||||
|
||||
/**
|
||||
* Whether at least one position fix has been received.
|
||||
*/
|
||||
ready: Readonly<Ref<boolean>>;
|
||||
|
||||
/**
|
||||
* Whether the position is currently being watched.
|
||||
*/
|
||||
isActive: Readonly<Ref<boolean>>;
|
||||
|
||||
/**
|
||||
* Start watching the position.
|
||||
*/
|
||||
resume: () => void;
|
||||
|
||||
/**
|
||||
* Stop watching the position.
|
||||
*/
|
||||
pause: () => void;
|
||||
|
||||
/**
|
||||
* Request the current position once, without starting a continuous watch.
|
||||
* Resolves with the position (and updates `coords`/`locatedAt`) or rejects
|
||||
* with a `GeolocationPositionError`.
|
||||
*/
|
||||
getCurrentPosition: () => Promise<GeolocationPosition>;
|
||||
}
|
||||
|
||||
const DEFAULT_COORDS: Omit<GeolocationPosition['coords'], 'toJSON'> = {
|
||||
accuracy: 0,
|
||||
latitude: Number.POSITIVE_INFINITY,
|
||||
longitude: Number.POSITIVE_INFINITY,
|
||||
altitude: null,
|
||||
altitudeAccuracy: null,
|
||||
heading: null,
|
||||
speed: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* @name useGeolocation
|
||||
* @category Browser
|
||||
* @description Reactive Geolocation API. Watches the device position, exposing
|
||||
* reactive coordinates, error, and readiness state, plus pause/resume controls
|
||||
* and a one-shot `getCurrentPosition`.
|
||||
*
|
||||
* @param {UseGeolocationOptions} [options={}] Options
|
||||
* @returns {UseGeolocationReturn} Reactive position, error, readiness, and watch controls
|
||||
*
|
||||
* @example
|
||||
* const { coords, locatedAt, error, ready } = useGeolocation();
|
||||
*
|
||||
* @example
|
||||
* // One-shot fetch without a continuous watch
|
||||
* const { getCurrentPosition } = useGeolocation({ immediate: false });
|
||||
* const position = await getCurrentPosition();
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useGeolocation(options: UseGeolocationOptions = {}): UseGeolocationReturn {
|
||||
const {
|
||||
enableHighAccuracy = true,
|
||||
maximumAge = 30000,
|
||||
timeout = 27000,
|
||||
navigator = defaultNavigator,
|
||||
immediate = true,
|
||||
onError = noop,
|
||||
} = options;
|
||||
|
||||
const isSupported = useSupported(() => navigator && 'geolocation' in navigator);
|
||||
|
||||
const locatedAt = shallowRef<number | null>(null);
|
||||
const error = shallowRef<GeolocationPositionError | null>(null);
|
||||
const coords = shallowRef<Omit<GeolocationPosition['coords'], 'toJSON'>>(DEFAULT_COORDS);
|
||||
const ready = shallowRef(false);
|
||||
const isActive = shallowRef(false);
|
||||
|
||||
function resolveOptions(): PositionOptions {
|
||||
return {
|
||||
enableHighAccuracy: toValue(enableHighAccuracy),
|
||||
maximumAge: toValue(maximumAge),
|
||||
timeout: toValue(timeout),
|
||||
};
|
||||
}
|
||||
|
||||
function updatePosition(position: GeolocationPosition): void {
|
||||
locatedAt.value = position.timestamp;
|
||||
coords.value = position.coords;
|
||||
error.value = null;
|
||||
ready.value = true;
|
||||
}
|
||||
|
||||
function handleError(err: GeolocationPositionError): void {
|
||||
error.value = err;
|
||||
onError(err);
|
||||
}
|
||||
|
||||
let watcher: number | null = null;
|
||||
|
||||
function resume(): void {
|
||||
if (!isSupported.value || !navigator || watcher !== null)
|
||||
return;
|
||||
|
||||
watcher = navigator.geolocation.watchPosition(updatePosition, handleError, resolveOptions());
|
||||
isActive.value = true;
|
||||
}
|
||||
|
||||
function pause(): void {
|
||||
if (watcher === null || !navigator)
|
||||
return;
|
||||
|
||||
navigator.geolocation.clearWatch(watcher);
|
||||
watcher = null;
|
||||
isActive.value = false;
|
||||
}
|
||||
|
||||
function getCurrentPosition(): Promise<GeolocationPosition> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!isSupported.value || !navigator) {
|
||||
reject(new Error('Geolocation is not supported'));
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
updatePosition(position);
|
||||
resolve(position);
|
||||
},
|
||||
(err) => {
|
||||
handleError(err);
|
||||
reject(err);
|
||||
},
|
||||
resolveOptions(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Restart the watcher when reactive position options change while active.
|
||||
watch(
|
||||
() => resolveOptions(),
|
||||
() => {
|
||||
if (isActive.value) {
|
||||
pause();
|
||||
resume();
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
if (immediate)
|
||||
resume();
|
||||
|
||||
tryOnScopeDispose(pause);
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
coords,
|
||||
locatedAt: shallowReadonly(locatedAt),
|
||||
error: shallowReadonly(error),
|
||||
ready: shallowReadonly(ready),
|
||||
isActive: shallowReadonly(isActive),
|
||||
resume,
|
||||
pause,
|
||||
getCurrentPosition,
|
||||
};
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope } from 'vue';
|
||||
import { useIdle } from '.';
|
||||
import { bypassFilter } from '@/utils/filters';
|
||||
|
||||
/**
|
||||
* Minimal EventTarget-like stub so we can drive listeners deterministically
|
||||
* without relying on jsdom's real window/document timing.
|
||||
*/
|
||||
function createTarget() {
|
||||
const listeners = new Map<string, Set<EventListener>>();
|
||||
return {
|
||||
addEventListener(type: string, listener: EventListener) {
|
||||
if (!listeners.has(type))
|
||||
listeners.set(type, new Set());
|
||||
listeners.get(type)!.add(listener);
|
||||
},
|
||||
removeEventListener(type: string, listener: EventListener) {
|
||||
listeners.get(type)?.delete(listener);
|
||||
},
|
||||
dispatch(type: string, event: Event = { type } as Event) {
|
||||
listeners.get(type)?.forEach(fn => fn(event));
|
||||
},
|
||||
count(type: string) {
|
||||
return listeners.get(type)?.size ?? 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createWindow(doc: ReturnType<typeof createTarget> & { hidden?: boolean }) {
|
||||
const win = createTarget() as ReturnType<typeof createTarget> & { document: typeof doc };
|
||||
win.document = doc;
|
||||
return win;
|
||||
}
|
||||
|
||||
describe(useIdle, () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1000);
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('starts not idle and exposes lastActive', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
const doc = createTarget();
|
||||
const window = createWindow(doc) as any;
|
||||
const { idle, lastActive, isPending } = useIdle(1000, { window });
|
||||
|
||||
expect(idle.value).toBeFalsy();
|
||||
expect(isPending.value).toBeTruthy();
|
||||
expect(lastActive.value).toBe(1000);
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('becomes idle after the timeout elapses with no activity', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
const doc = createTarget();
|
||||
const window = createWindow(doc) as any;
|
||||
const { idle } = useIdle(1000, { window });
|
||||
|
||||
expect(idle.value).toBeFalsy();
|
||||
vi.advanceTimersByTime(999);
|
||||
expect(idle.value).toBeFalsy();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(idle.value).toBeTruthy();
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('resets idle state and lastActive on user activity', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
const doc = createTarget();
|
||||
const window = createWindow(doc) as any;
|
||||
const { idle, lastActive } = useIdle(1000, { window, eventFilter: bypassFilter });
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(idle.value).toBeTruthy();
|
||||
|
||||
vi.setSystemTime(2500);
|
||||
window.dispatch('mousemove');
|
||||
|
||||
expect(idle.value).toBeFalsy();
|
||||
expect(lastActive.value).toBe(2500);
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('restarts the timeout after activity', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
const doc = createTarget();
|
||||
const window = createWindow(doc) as any;
|
||||
const { idle } = useIdle(1000, { window, eventFilter: bypassFilter });
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
window.dispatch('keydown');
|
||||
// 500ms more would have been idle without the reset
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(idle.value).toBeFalsy();
|
||||
// full timeout from the reset
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(idle.value).toBeTruthy();
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('honors a custom events list', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
const doc = createTarget();
|
||||
const window = createWindow(doc) as any;
|
||||
useIdle(1000, { window, events: ['keydown'] });
|
||||
|
||||
expect(window.count('keydown')).toBe(1);
|
||||
expect(window.count('mousemove')).toBe(0);
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('listens for visibilitychange by default and resets when visible', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
const doc = createTarget() as any;
|
||||
doc.hidden = false;
|
||||
const window = createWindow(doc) as any;
|
||||
const { idle } = useIdle(1000, { window, eventFilter: bypassFilter });
|
||||
|
||||
expect(doc.count('visibilitychange')).toBe(1);
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(idle.value).toBeTruthy();
|
||||
|
||||
doc.dispatch('visibilitychange');
|
||||
expect(idle.value).toBeFalsy();
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('ignores visibilitychange when the document is hidden', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
const doc = createTarget() as any;
|
||||
doc.hidden = true;
|
||||
const window = createWindow(doc) as any;
|
||||
const { idle } = useIdle(1000, { window, eventFilter: bypassFilter });
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(idle.value).toBeTruthy();
|
||||
|
||||
doc.dispatch('visibilitychange');
|
||||
expect(idle.value).toBeTruthy();
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not register visibilitychange when disabled', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
const doc = createTarget();
|
||||
const window = createWindow(doc) as any;
|
||||
useIdle(1000, { window, listenForVisibilityChange: false });
|
||||
|
||||
expect(doc.count('visibilitychange')).toBe(0);
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('respects initialState: true (starts idle, no timer)', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
const doc = createTarget();
|
||||
const window = createWindow(doc) as any;
|
||||
const { idle } = useIdle(1000, { window, initialState: true });
|
||||
|
||||
expect(idle.value).toBeTruthy();
|
||||
vi.advanceTimersByTime(5000);
|
||||
// no reset was scheduled, so it stays in the initial state
|
||||
expect(idle.value).toBeTruthy();
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reset() manually clears idle and restarts the timer', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
const doc = createTarget();
|
||||
const window = createWindow(doc) as any;
|
||||
const { idle, reset } = useIdle(1000, { window });
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(idle.value).toBeTruthy();
|
||||
|
||||
reset();
|
||||
expect(idle.value).toBeFalsy();
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(idle.value).toBeTruthy();
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('stop() halts tracking and start() resumes it', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
const doc = createTarget();
|
||||
const window = createWindow(doc) as any;
|
||||
const { idle, isPending, start, stop } = useIdle(1000, { window, eventFilter: bypassFilter });
|
||||
|
||||
stop();
|
||||
expect(isPending.value).toBeFalsy();
|
||||
expect(idle.value).toBeFalsy();
|
||||
|
||||
// events are ignored while stopped
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(idle.value).toBeFalsy();
|
||||
window.dispatch('mousemove');
|
||||
expect(idle.value).toBeFalsy();
|
||||
|
||||
start();
|
||||
expect(isPending.value).toBeTruthy();
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(idle.value).toBeTruthy();
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('removes listeners when the scope is disposed', () => {
|
||||
const scope = effectScope();
|
||||
let win: any;
|
||||
scope.run(() => {
|
||||
const doc = createTarget();
|
||||
win = createWindow(doc) as any;
|
||||
useIdle(1000, { window: win });
|
||||
expect(win.count('mousemove')).toBe(1);
|
||||
});
|
||||
scope.stop();
|
||||
expect(win.count('mousemove')).toBe(0);
|
||||
});
|
||||
|
||||
it('is SSR-safe when no window is available', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
// simulate an environment with no window (the guard sees a falsy target)
|
||||
const { idle, isPending, lastActive } = useIdle(1000, { window: null as any });
|
||||
|
||||
// never started: stays in initial state and never schedules a timer
|
||||
expect(idle.value).toBeFalsy();
|
||||
expect(isPending.value).toBeFalsy();
|
||||
expect(lastActive.value).toBe(1000);
|
||||
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(idle.value).toBeFalsy();
|
||||
});
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -1,169 +0,0 @@
|
||||
import { shallowReadonly, shallowRef } from 'vue';
|
||||
import type { ShallowRef } from 'vue';
|
||||
import { timestamp } from '@robonen/stdlib';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { createFilterWrapper, throttleFilter } from '@/utils/filters';
|
||||
import type { ConfigurableEventFilter } from '@/utils/filters';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
import type { WindowEventName } from '@/composables/browser/useEventListener';
|
||||
|
||||
const DEFAULT_EVENTS: WindowEventName[] = ['mousemove', 'mousedown', 'resize', 'keydown', 'touchstart', 'wheel'];
|
||||
const ONE_MINUTE = 60_000;
|
||||
|
||||
export interface UseIdleOptions extends ConfigurableWindow, ConfigurableEventFilter {
|
||||
/**
|
||||
* Event names to listen to for detecting user activity
|
||||
*
|
||||
* @default ['mousemove', 'mousedown', 'resize', 'keydown', 'touchstart', 'wheel']
|
||||
*/
|
||||
events?: WindowEventName[];
|
||||
|
||||
/**
|
||||
* Reset the idle timer when the document becomes visible again
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
listenForVisibilityChange?: boolean;
|
||||
|
||||
/**
|
||||
* Initial value of the `idle` ref
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
initialState?: boolean;
|
||||
}
|
||||
|
||||
export interface UseIdleReturn {
|
||||
/**
|
||||
* Whether the user is currently idle
|
||||
*/
|
||||
idle: ShallowRef<boolean>;
|
||||
|
||||
/**
|
||||
* Timestamp (ms) of the last detected user activity
|
||||
*/
|
||||
lastActive: ShallowRef<number>;
|
||||
|
||||
/**
|
||||
* Whether the idle tracker is currently running
|
||||
*/
|
||||
isPending: Readonly<ShallowRef<boolean>>;
|
||||
|
||||
/**
|
||||
* Manually mark the user as active and restart the idle timer
|
||||
*/
|
||||
reset: () => void;
|
||||
|
||||
/**
|
||||
* Begin (or resume) tracking. Restarts the idle timer unless `initialState` is `true`
|
||||
*/
|
||||
start: () => void;
|
||||
|
||||
/**
|
||||
* Stop tracking. Resets `idle` to `initialState` and clears the pending timer
|
||||
*/
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useIdle
|
||||
* @category Browser
|
||||
* @description Track whether the user has been inactive for a given duration.
|
||||
*
|
||||
* @param {number} [timeout=60000] Idle threshold in milliseconds
|
||||
* @param {UseIdleOptions} [options={}] Options
|
||||
* @returns {UseIdleReturn} `{ idle, lastActive, isPending, reset, start, stop }`
|
||||
*
|
||||
* @example
|
||||
* const { idle, lastActive, reset } = useIdle(5 * 60_000); // 5 minutes
|
||||
*
|
||||
* @example
|
||||
* const { idle } = useIdle(10_000, { events: ['keydown'] });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useIdle(
|
||||
timeout: number = ONE_MINUTE,
|
||||
options: UseIdleOptions = {},
|
||||
): UseIdleReturn {
|
||||
const {
|
||||
initialState = false,
|
||||
listenForVisibilityChange = true,
|
||||
events = DEFAULT_EVENTS,
|
||||
window = defaultWindow,
|
||||
eventFilter = throttleFilter(50),
|
||||
} = options;
|
||||
|
||||
const idle = shallowRef(initialState);
|
||||
const lastActive = shallowRef(timestamp());
|
||||
const isPending = shallowRef(false);
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const reset = (): void => {
|
||||
idle.value = false;
|
||||
if (timer !== undefined)
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
idle.value = true;
|
||||
}, timeout);
|
||||
};
|
||||
|
||||
const onEvent = createFilterWrapper(
|
||||
eventFilter,
|
||||
() => {
|
||||
lastActive.value = timestamp();
|
||||
reset();
|
||||
},
|
||||
);
|
||||
|
||||
const start = (): void => {
|
||||
if (isPending.value)
|
||||
return;
|
||||
isPending.value = true;
|
||||
if (!initialState)
|
||||
reset();
|
||||
};
|
||||
|
||||
const stop = (): void => {
|
||||
idle.value = initialState;
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
isPending.value = false;
|
||||
};
|
||||
|
||||
if (window) {
|
||||
const document = window.document;
|
||||
const listenerOptions = { passive: true };
|
||||
|
||||
for (const event of events) {
|
||||
useEventListener(window, event, () => {
|
||||
if (!isPending.value)
|
||||
return;
|
||||
onEvent();
|
||||
}, listenerOptions);
|
||||
}
|
||||
|
||||
if (listenForVisibilityChange) {
|
||||
useEventListener(document, 'visibilitychange', () => {
|
||||
if (document.hidden || !isPending.value)
|
||||
return;
|
||||
onEvent();
|
||||
}, listenerOptions);
|
||||
}
|
||||
|
||||
start();
|
||||
}
|
||||
|
||||
return {
|
||||
idle,
|
||||
lastActive,
|
||||
isPending: shallowReadonly(isPending),
|
||||
reset,
|
||||
start,
|
||||
stop,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { effectScope, nextTick, ref } from 'vue';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useImage } from '.';
|
||||
|
||||
interface FakeImage {
|
||||
src: string;
|
||||
srcset: string;
|
||||
sizes: string;
|
||||
alt: string;
|
||||
className: string;
|
||||
loading: string;
|
||||
crossOrigin: string | null;
|
||||
referrerPolicy: string;
|
||||
width: number;
|
||||
height: number;
|
||||
decoding: string;
|
||||
fetchPriority: string;
|
||||
isMap: boolean;
|
||||
useMap: string;
|
||||
onload: (() => void) | null;
|
||||
onerror: ((err: unknown) => void) | null;
|
||||
}
|
||||
|
||||
let lastImage: FakeImage | undefined;
|
||||
// Decides whether the next constructed image "loads" or "errors".
|
||||
let shouldFail = false;
|
||||
|
||||
function createImage(): FakeImage {
|
||||
const img: FakeImage = {
|
||||
src: '',
|
||||
srcset: '',
|
||||
sizes: '',
|
||||
alt: '',
|
||||
className: '',
|
||||
loading: '',
|
||||
crossOrigin: null,
|
||||
referrerPolicy: '',
|
||||
width: 0,
|
||||
height: 0,
|
||||
decoding: '',
|
||||
fetchPriority: '',
|
||||
isMap: false,
|
||||
useMap: '',
|
||||
onload: null,
|
||||
onerror: null,
|
||||
};
|
||||
|
||||
lastImage = img;
|
||||
|
||||
// Mimic the browser firing load/error asynchronously after src is set.
|
||||
queueMicrotask(() => {
|
||||
if (shouldFail)
|
||||
img.onerror?.(new Error('load-error'));
|
||||
else
|
||||
img.onload?.();
|
||||
});
|
||||
|
||||
return img;
|
||||
}
|
||||
|
||||
function createFakeWindow(): Window {
|
||||
const Image = function Image(): FakeImage {
|
||||
return createImage();
|
||||
} as unknown as new () => HTMLImageElement;
|
||||
|
||||
return { Image } as unknown as Window;
|
||||
}
|
||||
|
||||
describe(useImage, () => {
|
||||
let window: Window;
|
||||
|
||||
beforeEach(() => {
|
||||
lastImage = undefined;
|
||||
shouldFail = false;
|
||||
window = createFakeWindow();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('loads an image and exposes the element as state', async () => {
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useImage>;
|
||||
scope.run(() => {
|
||||
result = useImage({ src: '/cat.png' }, { window });
|
||||
});
|
||||
|
||||
expect(result.isLoading.value).toBeTruthy();
|
||||
expect(result.isReady.value).toBeFalsy();
|
||||
expect(result.error.value).toBe(null);
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(result.isLoading.value).toBeFalsy();
|
||||
expect(result.isReady.value).toBeTruthy();
|
||||
expect(result.error.value).toBe(null);
|
||||
expect(result.state.value).toBe(lastImage);
|
||||
expect(lastImage?.src).toBe('/cat.png');
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('applies the provided image attributes', async () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
useImage(
|
||||
{
|
||||
src: '/cat.png',
|
||||
srcset: '/cat-2x.png 2x',
|
||||
sizes: '100vw',
|
||||
alt: 'a cat',
|
||||
class: 'rounded',
|
||||
loading: 'lazy',
|
||||
crossorigin: 'anonymous',
|
||||
referrerPolicy: 'no-referrer',
|
||||
width: 320,
|
||||
height: 240,
|
||||
decoding: 'async',
|
||||
fetchPriority: 'high',
|
||||
ismap: true,
|
||||
usemap: '#map',
|
||||
},
|
||||
{ window },
|
||||
);
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(lastImage).toBeDefined();
|
||||
expect(lastImage!.src).toBe('/cat.png');
|
||||
expect(lastImage!.srcset).toBe('/cat-2x.png 2x');
|
||||
expect(lastImage!.sizes).toBe('100vw');
|
||||
expect(lastImage!.alt).toBe('a cat');
|
||||
expect(lastImage!.className).toBe('rounded');
|
||||
expect(lastImage!.loading).toBe('lazy');
|
||||
expect(lastImage!.crossOrigin).toBe('anonymous');
|
||||
expect(lastImage!.referrerPolicy).toBe('no-referrer');
|
||||
expect(lastImage!.width).toBe(320);
|
||||
expect(lastImage!.height).toBe(240);
|
||||
expect(lastImage!.decoding).toBe('async');
|
||||
expect(lastImage!.fetchPriority).toBe('high');
|
||||
expect(lastImage!.isMap).toBeTruthy();
|
||||
expect(lastImage!.useMap).toBe('#map');
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('captures load errors', async () => {
|
||||
shouldFail = true;
|
||||
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useImage>;
|
||||
scope.run(() => {
|
||||
result = useImage({ src: '/missing.png' }, { window });
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(result.isLoading.value).toBeFalsy();
|
||||
expect(result.isReady.value).toBeFalsy();
|
||||
expect(result.error.value).toBeInstanceOf(Error);
|
||||
expect(result.state.value).toBe(undefined);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reloads when a reactive source changes', async () => {
|
||||
const src = ref('/a.png');
|
||||
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useImage>;
|
||||
scope.run(() => {
|
||||
result = useImage(() => ({ src: src.value }), { window });
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(lastImage?.src).toBe('/a.png');
|
||||
expect(result.isReady.value).toBeTruthy();
|
||||
|
||||
src.value = '/b.png';
|
||||
await nextTick();
|
||||
|
||||
// resetOnExecute clears state and flips loading back on
|
||||
expect(result.isLoading.value).toBeTruthy();
|
||||
expect(result.state.value).toBe(undefined);
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(lastImage?.src).toBe('/b.png');
|
||||
expect(result.isReady.value).toBeTruthy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not set up a watcher for a plain options object', async () => {
|
||||
const watchSpy = vi.fn();
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
const result = useImage({ src: '/static.png' }, { window });
|
||||
// execute is the only thing a reload would call; spy after initial run
|
||||
result.execute = watchSpy;
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(watchSpy).not.toHaveBeenCalled();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not call execute immediately when immediate is false', async () => {
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useImage>;
|
||||
scope.run(() => {
|
||||
result = useImage({ src: '/cat.png' }, { window, immediate: false });
|
||||
});
|
||||
|
||||
expect(result.isLoading.value).toBeFalsy();
|
||||
expect(lastImage).toBeUndefined();
|
||||
|
||||
await result.execute();
|
||||
expect(lastImage?.src).toBe('/cat.png');
|
||||
expect(result.isReady.value).toBeTruthy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('rejects when no Image constructor is available (SSR path)', async () => {
|
||||
const onError = vi.fn();
|
||||
// A window without an Image constructor stands in for a non-DOM environment.
|
||||
const ssrWindow = {} as unknown as Window;
|
||||
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useImage>;
|
||||
scope.run(() => {
|
||||
result = useImage({ src: '/cat.png' }, { window: ssrWindow, onError });
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(result.isReady.value).toBeFalsy();
|
||||
expect(result.error.value).toBeInstanceOf(Error);
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { isRef, toValue, watch } from 'vue';
|
||||
import type { MaybeRefOrGetter } from 'vue';
|
||||
import { isFunction } from '@robonen/stdlib';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { useAsyncState } from '@/composables/state/useAsyncState';
|
||||
import type { UseAsyncStateOptions, UseAsyncStateReturn } from '@/composables/state/useAsyncState';
|
||||
|
||||
export interface UseImageOptions {
|
||||
/** Address of the resource */
|
||||
src: string;
|
||||
/** Images to use in different situations, e.g. high-resolution displays, small monitors, etc. */
|
||||
srcset?: string;
|
||||
/** Image sizes for different page layouts */
|
||||
sizes?: string;
|
||||
/** Image alternative information */
|
||||
alt?: string;
|
||||
/** Image classes */
|
||||
class?: string;
|
||||
/** Image loading strategy */
|
||||
loading?: HTMLImageElement['loading'];
|
||||
/** Image CORS settings */
|
||||
crossorigin?: string;
|
||||
/** Referrer policy for fetch — https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy */
|
||||
referrerPolicy?: HTMLImageElement['referrerPolicy'];
|
||||
/** Image width */
|
||||
width?: HTMLImageElement['width'];
|
||||
/** Image height */
|
||||
height?: HTMLImageElement['height'];
|
||||
/** Image decoding hint — https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#decoding */
|
||||
decoding?: HTMLImageElement['decoding'];
|
||||
/** Relative priority hint for fetching the image */
|
||||
fetchPriority?: HTMLImageElement['fetchPriority'];
|
||||
/** Whether the image is a server-side image map */
|
||||
ismap?: HTMLImageElement['isMap'];
|
||||
/** Partial URL (starting with #) of an image map associated with the element */
|
||||
usemap?: HTMLImageElement['useMap'];
|
||||
}
|
||||
|
||||
export interface UseImageAsyncStateOptions
|
||||
extends UseAsyncStateOptions<true, HTMLImageElement | undefined>, ConfigurableWindow {}
|
||||
|
||||
export type UseImageReturn = UseAsyncStateReturn<HTMLImageElement | undefined, any[], true>;
|
||||
|
||||
interface LoadImageContext {
|
||||
window?: Window;
|
||||
}
|
||||
|
||||
function loadImage(options: UseImageOptions, ctx: LoadImageContext): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// `Image` is a global constructor on `typeof globalThis`, not the `Window` interface.
|
||||
const ImageCtor = (ctx.window as (Window & typeof globalThis) | undefined)?.Image;
|
||||
|
||||
if (!ImageCtor) {
|
||||
reject(new Error('useImage: no Image constructor available (are you running on the server?)'));
|
||||
return;
|
||||
}
|
||||
|
||||
const img = new ImageCtor();
|
||||
const {
|
||||
src,
|
||||
srcset,
|
||||
sizes,
|
||||
alt,
|
||||
class: className,
|
||||
loading,
|
||||
crossorigin,
|
||||
referrerPolicy,
|
||||
width,
|
||||
height,
|
||||
decoding,
|
||||
fetchPriority,
|
||||
ismap,
|
||||
usemap,
|
||||
} = options;
|
||||
|
||||
if (alt !== undefined) img.alt = alt;
|
||||
if (className !== undefined) img.className = className;
|
||||
if (loading !== undefined) img.loading = loading;
|
||||
if (crossorigin !== undefined) img.crossOrigin = crossorigin;
|
||||
if (referrerPolicy !== undefined) img.referrerPolicy = referrerPolicy;
|
||||
if (width !== undefined) img.width = width;
|
||||
if (height !== undefined) img.height = height;
|
||||
if (decoding !== undefined) img.decoding = decoding;
|
||||
if (fetchPriority !== undefined) img.fetchPriority = fetchPriority;
|
||||
if (ismap !== undefined) img.isMap = ismap;
|
||||
if (usemap !== undefined) img.useMap = usemap;
|
||||
|
||||
// Setting srcset/sizes before src lets the browser pick the right candidate up-front.
|
||||
if (sizes !== undefined) img.sizes = sizes;
|
||||
if (srcset !== undefined) img.srcset = srcset;
|
||||
img.src = src;
|
||||
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useImage
|
||||
* @category Browser
|
||||
* @description Reactively load an image in the browser; await the result to render it or show a fallback.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<UseImageOptions>} options Image attributes (as used on the `<img>` tag); pass a ref/getter to reload reactively
|
||||
* @param {UseImageAsyncStateOptions} [asyncStateOptions={}] `useAsyncState` options (`delay`, `immediate`, `onError`, …) plus a configurable `window`
|
||||
* @returns {UseImageReturn} `useAsyncState`-shaped `{ isLoading, isReady, error, state, execute, … }` for an `HTMLImageElement`
|
||||
*
|
||||
* @example
|
||||
* const { isLoading, error, state: image } = useImage({ src: '/cat.png' });
|
||||
*
|
||||
* @example
|
||||
* // Reactive source: reloads whenever `src` changes
|
||||
* const src = ref('/a.png');
|
||||
* const { state } = useImage(() => ({ src: src.value, alt: 'photo' }));
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useImage(
|
||||
options: MaybeRefOrGetter<UseImageOptions>,
|
||||
asyncStateOptions: UseImageAsyncStateOptions = {},
|
||||
): UseImageReturn {
|
||||
const { window = defaultWindow, ...stateOptions } = asyncStateOptions;
|
||||
|
||||
const state = useAsyncState<HTMLImageElement | undefined>(
|
||||
() => loadImage(toValue(options), { window }),
|
||||
undefined,
|
||||
{
|
||||
resetOnExecute: true,
|
||||
...stateOptions,
|
||||
},
|
||||
);
|
||||
|
||||
// A plain (non-ref, non-getter) options object can never change, so we skip
|
||||
// the watcher entirely — no needless deep traversal on every tick.
|
||||
if (isRef(options) || isFunction(options)) {
|
||||
watch(
|
||||
() => toValue(options),
|
||||
() => state.execute(stateOptions.delay),
|
||||
{ deep: true },
|
||||
);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick, ref } from 'vue';
|
||||
import { useIntersectionObserver } from '.';
|
||||
|
||||
interface StubInstance {
|
||||
cb: IntersectionObserverCallback;
|
||||
options?: IntersectionObserverInit;
|
||||
observe: ReturnType<typeof vi.fn>;
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
let instances: StubInstance[] = [];
|
||||
|
||||
class StubIntersectionObserver {
|
||||
observe = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
unobserve = vi.fn();
|
||||
takeRecords = vi.fn();
|
||||
cb: IntersectionObserverCallback;
|
||||
options?: IntersectionObserverInit;
|
||||
constructor(cb: IntersectionObserverCallback, options?: IntersectionObserverInit) {
|
||||
this.cb = cb;
|
||||
this.options = options;
|
||||
instances.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
describe(useIntersectionObserver, () => {
|
||||
beforeEach(() => {
|
||||
instances = [];
|
||||
vi.stubGlobal('IntersectionObserver', StubIntersectionObserver);
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('observes the target immediately', () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
scope.run(() => useIntersectionObserver(ref(el), vi.fn()));
|
||||
|
||||
expect(instances).toHaveLength(1);
|
||||
expect(instances[0]!.observe).toHaveBeenCalledWith(el);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not observe when immediate is false', () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
scope.run(() => useIntersectionObserver(ref(el), vi.fn(), { immediate: false }));
|
||||
|
||||
expect(instances).toHaveLength(0);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('pause disconnects and resume re-observes', async () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let controls: ReturnType<typeof useIntersectionObserver>;
|
||||
scope.run(() => {
|
||||
controls = useIntersectionObserver(ref(el), vi.fn());
|
||||
});
|
||||
|
||||
controls!.pause();
|
||||
expect(instances[0]!.disconnect).toHaveBeenCalled();
|
||||
expect(controls!.isActive.value).toBeFalsy();
|
||||
|
||||
controls!.resume();
|
||||
await nextTick();
|
||||
expect(controls!.isActive.value).toBeTruthy();
|
||||
expect(instances).toHaveLength(2);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('stop disconnects and marks inactive', () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let controls: ReturnType<typeof useIntersectionObserver>;
|
||||
scope.run(() => {
|
||||
controls = useIntersectionObserver(ref(el), vi.fn());
|
||||
});
|
||||
|
||||
controls!.stop();
|
||||
expect(instances[0]!.disconnect).toHaveBeenCalled();
|
||||
expect(controls!.isActive.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('invokes the callback with entries', () => {
|
||||
const el = document.createElement('div');
|
||||
const callback = vi.fn();
|
||||
const scope = effectScope();
|
||||
scope.run(() => useIntersectionObserver(ref(el), callback));
|
||||
|
||||
const entry = { isIntersecting: true, time: 1 } as IntersectionObserverEntry;
|
||||
instances[0]!.cb([entry], instances[0] as unknown as IntersectionObserver);
|
||||
expect(callback).toHaveBeenCalled();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('observes an array of targets', () => {
|
||||
const a = document.createElement('div');
|
||||
const b = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
scope.run(() => useIntersectionObserver([ref(a), ref(b)], vi.fn()));
|
||||
|
||||
expect(instances).toHaveLength(1);
|
||||
expect(instances[0]!.observe).toHaveBeenCalledWith(a);
|
||||
expect(instances[0]!.observe).toHaveBeenCalledWith(b);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('tracks a reactive target ref of an array', async () => {
|
||||
const a = document.createElement('div');
|
||||
const b = document.createElement('div');
|
||||
const list = ref([a]);
|
||||
const scope = effectScope();
|
||||
scope.run(() => useIntersectionObserver(list, vi.fn()));
|
||||
|
||||
expect(instances).toHaveLength(1);
|
||||
expect(instances[0]!.observe).toHaveBeenCalledTimes(1);
|
||||
|
||||
list.value = [a, b];
|
||||
await nextTick();
|
||||
|
||||
// recreated with both elements
|
||||
expect(instances).toHaveLength(2);
|
||||
expect(instances[1]!.observe).toHaveBeenCalledWith(a);
|
||||
expect(instances[1]!.observe).toHaveBeenCalledWith(b);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('tracks a getter target', async () => {
|
||||
const a = document.createElement('div');
|
||||
const enabled = ref(false);
|
||||
const scope = effectScope();
|
||||
scope.run(() => useIntersectionObserver(() => (enabled.value ? a : null), vi.fn()));
|
||||
|
||||
// null target -> no observer
|
||||
expect(instances).toHaveLength(0);
|
||||
|
||||
enabled.value = true;
|
||||
await nextTick();
|
||||
expect(instances).toHaveLength(1);
|
||||
expect(instances[0]!.observe).toHaveBeenCalledWith(a);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('passes rootMargin and threshold to the observer', () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
scope.run(() => useIntersectionObserver(ref(el), vi.fn(), { rootMargin: '10px', threshold: [0, 0.5, 1] }));
|
||||
|
||||
expect(instances[0]!.options?.rootMargin).toBe('10px');
|
||||
expect(instances[0]!.options?.threshold).toEqual([0, 0.5, 1]);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reacts to a reactive rootMargin', async () => {
|
||||
const el = document.createElement('div');
|
||||
const rootMargin = ref('0px');
|
||||
const scope = effectScope();
|
||||
scope.run(() => useIntersectionObserver(ref(el), vi.fn(), { rootMargin }));
|
||||
|
||||
expect(instances[0]!.options?.rootMargin).toBe('0px');
|
||||
|
||||
rootMargin.value = '20px';
|
||||
await nextTick();
|
||||
|
||||
expect(instances).toHaveLength(2);
|
||||
expect(instances[1]!.options?.rootMargin).toBe('20px');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reacts to a reactive threshold', async () => {
|
||||
const el = document.createElement('div');
|
||||
const threshold = ref<number | number[]>(0);
|
||||
const scope = effectScope();
|
||||
scope.run(() => useIntersectionObserver(ref(el), vi.fn(), { threshold }));
|
||||
|
||||
expect(instances[0]!.options?.threshold).toBe(0);
|
||||
|
||||
threshold.value = 0.75;
|
||||
await nextTick();
|
||||
|
||||
expect(instances).toHaveLength(2);
|
||||
expect(instances[1]!.options?.threshold).toBe(0.75);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reports unsupported and never constructs an observer', () => {
|
||||
// jsdom has no native IntersectionObserver; remove the stub so the
|
||||
// feature detection `'IntersectionObserver' in window` reports false.
|
||||
vi.unstubAllGlobals();
|
||||
delete (globalThis as Record<string, unknown>).IntersectionObserver;
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let controls: ReturnType<typeof useIntersectionObserver>;
|
||||
scope.run(() => {
|
||||
controls = useIntersectionObserver(ref(el), vi.fn());
|
||||
});
|
||||
|
||||
expect(controls!.isSupported.value).toBeFalsy();
|
||||
// stop should be a safe no-op
|
||||
expect(() => controls!.stop()).not.toThrow();
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -1,150 +0,0 @@
|
||||
import { computed, readonly, ref, toValue, watch } from 'vue';
|
||||
import type { MaybeRefOrGetter, Ref } from 'vue';
|
||||
import { noop, toArray } from '@robonen/stdlib';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { MaybeComputedElementRef, MaybeElement } from '@/composables/component/unrefElement';
|
||||
import { unrefElement } from '@/composables/component/unrefElement';
|
||||
import { useSupported } from '@/composables/browser/useSupported';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
|
||||
export interface UseIntersectionObserverOptions extends ConfigurableWindow {
|
||||
/**
|
||||
* The element or document used as the viewport for checking visibility
|
||||
*/
|
||||
root?: MaybeComputedElementRef | Document;
|
||||
|
||||
/**
|
||||
* Margin around the root. Reactive — pass a ref or getter to update it.
|
||||
*
|
||||
* @default '0px'
|
||||
*/
|
||||
rootMargin?: MaybeRefOrGetter<string>;
|
||||
|
||||
/**
|
||||
* Threshold(s) at which to trigger the callback. Reactive — pass a ref or
|
||||
* getter to update it.
|
||||
*
|
||||
* @default 0
|
||||
*/
|
||||
threshold?: MaybeRefOrGetter<number | number[]>;
|
||||
|
||||
/**
|
||||
* Start observing immediately
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
immediate?: boolean;
|
||||
}
|
||||
|
||||
export interface UseIntersectionObserverReturn {
|
||||
isSupported: Readonly<Ref<boolean>>;
|
||||
isActive: Readonly<Ref<boolean>>;
|
||||
pause: () => void;
|
||||
resume: () => void;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useIntersectionObserver
|
||||
* @category Browser
|
||||
* @description Detect when an element enters or leaves the viewport via
|
||||
* `IntersectionObserver`. Accepts a single target, an array of targets, or a
|
||||
* ref/getter resolving to either, plus reactive `rootMargin` and `threshold`.
|
||||
*
|
||||
* @param {MaybeComputedElementRef | MaybeComputedElementRef[] | MaybeRefOrGetter<MaybeElement[]>} target Element(s) to observe
|
||||
* @param {IntersectionObserverCallback} callback Invoked with the observer entries
|
||||
* @param {UseIntersectionObserverOptions} [options={}] Options
|
||||
* @returns {UseIntersectionObserverReturn} Observer controls
|
||||
*
|
||||
* @example
|
||||
* useIntersectionObserver(el, ([{ isIntersecting }]) => {
|
||||
* visible.value = isIntersecting;
|
||||
* });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useIntersectionObserver(
|
||||
target: MaybeComputedElementRef | MaybeComputedElementRef[] | MaybeRefOrGetter<MaybeElement[]>,
|
||||
callback: IntersectionObserverCallback,
|
||||
options: UseIntersectionObserverOptions = {},
|
||||
): UseIntersectionObserverReturn {
|
||||
const {
|
||||
root,
|
||||
rootMargin = '0px',
|
||||
threshold = 0,
|
||||
window = defaultWindow,
|
||||
immediate = true,
|
||||
} = options;
|
||||
|
||||
const isSupported = useSupported(() => window && 'IntersectionObserver' in window);
|
||||
|
||||
const targets = computed(() => {
|
||||
const value = toValue(target) as MaybeElement | MaybeElement[];
|
||||
return toArray(value as MaybeElement)
|
||||
.map(el => unrefElement(el))
|
||||
.filter((el): el is Element => Boolean(el));
|
||||
});
|
||||
|
||||
const isActive = ref(immediate);
|
||||
|
||||
let cleanup = noop;
|
||||
|
||||
const stopWatch = isSupported.value
|
||||
? watch(
|
||||
() => [
|
||||
targets.value,
|
||||
unrefElement(root as MaybeComputedElementRef),
|
||||
toValue(rootMargin),
|
||||
toValue(threshold),
|
||||
isActive.value,
|
||||
] as const,
|
||||
([els, rootEl, margin, thresh, active]) => {
|
||||
cleanup();
|
||||
|
||||
if (!active || !els.length)
|
||||
return;
|
||||
|
||||
const observer = new IntersectionObserver(callback, {
|
||||
root: (rootEl as Element | null) ?? (root as Document | undefined),
|
||||
rootMargin: margin,
|
||||
threshold: thresh,
|
||||
});
|
||||
|
||||
for (const el of els)
|
||||
observer.observe(el);
|
||||
|
||||
cleanup = () => {
|
||||
observer.disconnect();
|
||||
cleanup = noop;
|
||||
};
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
)
|
||||
: noop;
|
||||
|
||||
const resume = (): void => {
|
||||
isActive.value = true;
|
||||
};
|
||||
|
||||
const pause = (): void => {
|
||||
cleanup();
|
||||
isActive.value = false;
|
||||
};
|
||||
|
||||
const stop = (): void => {
|
||||
cleanup();
|
||||
stopWatch();
|
||||
isActive.value = false;
|
||||
};
|
||||
|
||||
tryOnScopeDispose(stop);
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
isActive: readonly(isActive),
|
||||
pause,
|
||||
resume,
|
||||
stop,
|
||||
};
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { defineComponent, effectScope, nextTick, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { useIntervalFn } from '.';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const ComponentStub = defineComponent({
|
||||
props: {
|
||||
callback: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
interval: {
|
||||
type: Number,
|
||||
default: 1000,
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const result = useIntervalFn(props.callback as () => void, props.interval, props.options);
|
||||
return { ...result };
|
||||
},
|
||||
template: '<div>{{ isActive }}</div>',
|
||||
});
|
||||
|
||||
describe(useIntervalFn, () => {
|
||||
it('starts immediately by default', () => {
|
||||
const callback = vi.fn();
|
||||
const wrapper = mount(ComponentStub, {
|
||||
props: { callback },
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toBe('true');
|
||||
});
|
||||
|
||||
it('does not start when immediate is false', () => {
|
||||
const callback = vi.fn();
|
||||
mount(ComponentStub, {
|
||||
props: {
|
||||
callback,
|
||||
options: { immediate: false },
|
||||
},
|
||||
});
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls callback on each interval', () => {
|
||||
const callback = vi.fn();
|
||||
mount(ComponentStub, {
|
||||
props: { callback, interval: 500 },
|
||||
});
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
|
||||
vi.advanceTimersByTime(1500);
|
||||
expect(callback).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('calls callback immediately when immediateCallback is true', () => {
|
||||
const callback = vi.fn();
|
||||
mount(ComponentStub, {
|
||||
props: {
|
||||
callback,
|
||||
interval: 1000,
|
||||
options: { immediateCallback: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('pauses and resumes', async () => {
|
||||
const callback = vi.fn();
|
||||
const wrapper = mount(ComponentStub, {
|
||||
props: { callback, interval: 100 },
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(callback).toHaveBeenCalledTimes(3);
|
||||
|
||||
wrapper.vm.pause();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).toBe('false');
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(callback).toHaveBeenCalledTimes(3);
|
||||
|
||||
wrapper.vm.resume();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).toBe('true');
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(callback).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('toggles the interval', async () => {
|
||||
const callback = vi.fn();
|
||||
const wrapper = mount(ComponentStub, {
|
||||
props: { callback },
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toBe('true');
|
||||
|
||||
wrapper.vm.toggle();
|
||||
await nextTick();
|
||||
expect(wrapper.text()).toBe('false');
|
||||
|
||||
wrapper.vm.toggle();
|
||||
await nextTick();
|
||||
expect(wrapper.text()).toBe('true');
|
||||
});
|
||||
|
||||
it('supports reactive interval', async () => {
|
||||
const callback = vi.fn();
|
||||
const interval = ref(1000);
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
useIntervalFn(callback, interval);
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Change interval to 200ms — watcher triggers async
|
||||
interval.value = 200;
|
||||
await nextTick();
|
||||
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(callback).toHaveBeenCalledTimes(3);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not fire with interval <= 0', () => {
|
||||
const callback = vi.fn();
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
const { isActive } = useIntervalFn(callback, 0);
|
||||
expect(isActive.value).toBeFalsy();
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('cleans up on scope dispose', () => {
|
||||
const callback = vi.fn();
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
useIntervalFn(callback, 100);
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(callback).toHaveBeenCalledTimes(3);
|
||||
|
||||
scope.stop();
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(callback).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('cleans up on component unmount', () => {
|
||||
const callback = vi.fn();
|
||||
const wrapper = mount(ComponentStub, {
|
||||
props: { callback, interval: 100 },
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(callback).toHaveBeenCalledTimes(3);
|
||||
|
||||
wrapper.unmount();
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(callback).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('resume is idempotent when already active', () => {
|
||||
const callback = vi.fn();
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useIntervalFn>;
|
||||
|
||||
scope.run(() => {
|
||||
result = useIntervalFn(callback, 100);
|
||||
});
|
||||
|
||||
expect(result!.isActive.value).toBeTruthy();
|
||||
result!.resume();
|
||||
expect(result!.isActive.value).toBeTruthy();
|
||||
|
||||
// Should still tick normally — no double interval
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('pause is idempotent when already paused', () => {
|
||||
const callback = vi.fn();
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useIntervalFn>;
|
||||
|
||||
scope.run(() => {
|
||||
result = useIntervalFn(callback, 100, { immediate: false });
|
||||
});
|
||||
|
||||
expect(result!.isActive.value).toBeFalsy();
|
||||
result!.pause();
|
||||
expect(result!.isActive.value).toBeFalsy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('uses default interval of 1000ms', () => {
|
||||
const callback = vi.fn();
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
useIntervalFn(callback);
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(999);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -1,112 +0,0 @@
|
||||
import { readonly, ref, toValue, watch } from 'vue';
|
||||
import type { MaybeRefOrGetter, Ref } from 'vue';
|
||||
import type { ResumableActions, ResumableOptions } from '@/types';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
|
||||
export interface UseIntervalFnOptions extends ResumableOptions {
|
||||
/**
|
||||
* Whether to invoke the callback immediately on start.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
immediateCallback?: boolean;
|
||||
}
|
||||
|
||||
export interface UseIntervalFnReturn extends ResumableActions {
|
||||
/**
|
||||
* Whether the interval is currently active
|
||||
*/
|
||||
isActive: Readonly<Ref<boolean>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a function on every interval. Supports reactive interval duration,
|
||||
* pause/resume, and automatic cleanup on scope dispose.
|
||||
*
|
||||
* @param callback - Function to call on every interval tick
|
||||
* @param interval - Interval duration in milliseconds (can be reactive)
|
||||
* @param options - Configuration options
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const { pause, resume, isActive } = useIntervalFn(() => {
|
||||
* console.log('tick');
|
||||
* }, 1000);
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Reactive interval
|
||||
* const delay = ref(1000);
|
||||
* useIntervalFn(() => console.log('tick'), delay);
|
||||
* delay.value = 500; // interval restarts with new duration
|
||||
* ```
|
||||
*/
|
||||
export function useIntervalFn(
|
||||
callback: () => void,
|
||||
interval: MaybeRefOrGetter<number> = 1000,
|
||||
options: UseIntervalFnOptions = {},
|
||||
): UseIntervalFnReturn {
|
||||
const {
|
||||
immediate = true,
|
||||
immediateCallback = false,
|
||||
} = options;
|
||||
|
||||
const isActive = ref(false);
|
||||
|
||||
let timerId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function clean() {
|
||||
if (timerId !== null) {
|
||||
clearInterval(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function resume() {
|
||||
const ms = toValue(interval);
|
||||
|
||||
if (ms <= 0)
|
||||
return;
|
||||
|
||||
isActive.value = true;
|
||||
|
||||
if (immediateCallback)
|
||||
callback();
|
||||
|
||||
clean();
|
||||
timerId = setInterval(callback, ms);
|
||||
}
|
||||
|
||||
function pause() {
|
||||
isActive.value = false;
|
||||
clean();
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (isActive.value)
|
||||
pause();
|
||||
else
|
||||
resume();
|
||||
}
|
||||
|
||||
// Re-start when interval changes reactively
|
||||
watch(() => toValue(interval), () => {
|
||||
if (isActive.value) {
|
||||
clean();
|
||||
resume();
|
||||
}
|
||||
});
|
||||
|
||||
if (immediate)
|
||||
resume();
|
||||
|
||||
tryOnScopeDispose(pause);
|
||||
|
||||
return {
|
||||
isActive: readonly(isActive),
|
||||
pause,
|
||||
resume,
|
||||
toggle,
|
||||
};
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick } from 'vue';
|
||||
import { useKeyModifier } from '.';
|
||||
import type { KeyModifier } from '.';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
/**
|
||||
* Dispatch an event on `document` whose `getModifierState(modifier)` resolves to `active`.
|
||||
* jsdom does not track real modifier state, so we stub the method per event.
|
||||
*/
|
||||
function dispatchModifier(
|
||||
type: string,
|
||||
active: boolean,
|
||||
modifier: KeyModifier = 'Shift',
|
||||
withGetModifierState = true,
|
||||
) {
|
||||
const event = new Event(type) as Event & {
|
||||
getModifierState?: (key: string) => boolean;
|
||||
};
|
||||
|
||||
if (withGetModifierState) {
|
||||
event.getModifierState = (key: string) => (key === modifier ? active : false);
|
||||
}
|
||||
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
describe(useKeyModifier, () => {
|
||||
it('defaults to null until the first matching event', () => {
|
||||
const scope = effectScope();
|
||||
let state: ReturnType<typeof useKeyModifier>;
|
||||
scope.run(() => {
|
||||
state = useKeyModifier('Shift');
|
||||
});
|
||||
|
||||
expect(state!.value).toBeNull();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('respects a provided initial value', () => {
|
||||
const scope = effectScope();
|
||||
let state: ReturnType<typeof useKeyModifier<boolean>>;
|
||||
scope.run(() => {
|
||||
state = useKeyModifier('Shift', { initial: false });
|
||||
});
|
||||
|
||||
expect(state!.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('updates when a default event reports the modifier active', async () => {
|
||||
const scope = effectScope();
|
||||
let state: ReturnType<typeof useKeyModifier>;
|
||||
scope.run(() => {
|
||||
state = useKeyModifier('Shift');
|
||||
});
|
||||
|
||||
dispatchModifier('keydown', true, 'Shift');
|
||||
await nextTick();
|
||||
expect(state!.value).toBeTruthy();
|
||||
|
||||
dispatchModifier('keyup', false, 'Shift');
|
||||
await nextTick();
|
||||
expect(state!.value).toBeFalsy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('tracks each modifier independently', async () => {
|
||||
const scope = effectScope();
|
||||
let caps: ReturnType<typeof useKeyModifier>;
|
||||
scope.run(() => {
|
||||
caps = useKeyModifier('CapsLock');
|
||||
});
|
||||
|
||||
dispatchModifier('keydown', true, 'CapsLock');
|
||||
await nextTick();
|
||||
expect(caps!.value).toBeTruthy();
|
||||
|
||||
// An event reporting a different modifier (Shift) must not flip CapsLock
|
||||
dispatchModifier('keydown', true, 'Shift');
|
||||
await nextTick();
|
||||
expect(caps!.value).toBeFalsy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('only listens on the configured events', async () => {
|
||||
const scope = effectScope();
|
||||
let state: ReturnType<typeof useKeyModifier>;
|
||||
scope.run(() => {
|
||||
state = useKeyModifier('Shift', { events: ['keydown'] });
|
||||
});
|
||||
|
||||
dispatchModifier('keyup', true, 'Shift');
|
||||
await nextTick();
|
||||
expect(state!.value).toBeNull();
|
||||
|
||||
dispatchModifier('keydown', true, 'Shift');
|
||||
await nextTick();
|
||||
expect(state!.value).toBeTruthy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('ignores events without getModifierState', async () => {
|
||||
const scope = effectScope();
|
||||
let state: ReturnType<typeof useKeyModifier>;
|
||||
scope.run(() => {
|
||||
state = useKeyModifier('Shift', { initial: false });
|
||||
});
|
||||
|
||||
dispatchModifier('keydown', true, 'Shift', false);
|
||||
await nextTick();
|
||||
expect(state!.value).toBeFalsy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('removes its listeners when the scope is disposed', async () => {
|
||||
const scope = effectScope();
|
||||
let state: ReturnType<typeof useKeyModifier>;
|
||||
scope.run(() => {
|
||||
state = useKeyModifier('Shift');
|
||||
});
|
||||
|
||||
scope.stop();
|
||||
|
||||
dispatchModifier('keydown', true, 'Shift');
|
||||
await nextTick();
|
||||
expect(state!.value).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts a custom document and listens on it', async () => {
|
||||
const listeners = new Map<string, Set<EventListener>>();
|
||||
const fakeDocument = {
|
||||
addEventListener(type: string, listener: EventListener) {
|
||||
if (!listeners.has(type))
|
||||
listeners.set(type, new Set());
|
||||
listeners.get(type)!.add(listener);
|
||||
},
|
||||
removeEventListener(type: string, listener: EventListener) {
|
||||
listeners.get(type)?.delete(listener);
|
||||
},
|
||||
} as unknown as Document;
|
||||
|
||||
const scope = effectScope();
|
||||
let state: ReturnType<typeof useKeyModifier>;
|
||||
scope.run(() => {
|
||||
state = useKeyModifier('Meta', { document: fakeDocument });
|
||||
});
|
||||
|
||||
const event = { getModifierState: (k: string) => k === 'Meta' } as unknown as KeyboardEvent;
|
||||
listeners.get('keydown')!.forEach(fn => fn(event));
|
||||
await nextTick();
|
||||
expect(state!.value).toBeTruthy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('is a no-op under SSR (no document)', () => {
|
||||
const scope = effectScope();
|
||||
let state: ReturnType<typeof useKeyModifier>;
|
||||
scope.run(() => {
|
||||
state = useKeyModifier('Shift', { document: undefined, initial: false });
|
||||
});
|
||||
|
||||
expect(state!.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import { shallowRef } from 'vue';
|
||||
import type { ShallowRef } from 'vue';
|
||||
import { isFunction } from '@robonen/stdlib';
|
||||
import { defaultDocument } from '@/types';
|
||||
import type { ConfigurableDocument } from '@/types';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
import type { DocumentEventName } from '@/composables/browser/useEventListener';
|
||||
|
||||
export type KeyModifier
|
||||
= | 'Alt'
|
||||
| 'AltGraph'
|
||||
| 'CapsLock'
|
||||
| 'Control'
|
||||
| 'Fn'
|
||||
| 'FnLock'
|
||||
| 'Meta'
|
||||
| 'NumLock'
|
||||
| 'ScrollLock'
|
||||
| 'Shift'
|
||||
| 'Symbol'
|
||||
| 'SymbolLock';
|
||||
|
||||
const DEFAULT_EVENTS: DocumentEventName[] = ['mousedown', 'mouseup', 'keydown', 'keyup'];
|
||||
|
||||
export interface UseKeyModifierOptions<Initial> extends ConfigurableDocument {
|
||||
/**
|
||||
* Event names that will prompt an update to the modifier state.
|
||||
*
|
||||
* @default ['mousedown', 'mouseup', 'keydown', 'keyup']
|
||||
*/
|
||||
events?: DocumentEventName[];
|
||||
|
||||
/**
|
||||
* Initial value of the returned ref.
|
||||
*
|
||||
* @default null
|
||||
*/
|
||||
initial?: Initial;
|
||||
}
|
||||
|
||||
export type UseKeyModifierReturn<Initial> = ShallowRef<Initial extends boolean ? boolean : boolean | null>;
|
||||
|
||||
/**
|
||||
* @name useKeyModifier
|
||||
* @category Browser
|
||||
* @description Reactive state of a keyboard modifier (CapsLock, NumLock, Shift, Control, Alt, Meta, ...) tracked via `KeyboardEvent.getModifierState`.
|
||||
*
|
||||
* @param {KeyModifier} modifier The modifier key to observe
|
||||
* @param {UseKeyModifierOptions} [options={}] Options (`events` to listen on, `initial` value, custom `document`)
|
||||
* @returns {UseKeyModifierReturn} A shallow ref holding the current modifier state (`null` until the first matching event)
|
||||
*
|
||||
* @example
|
||||
* const capsLock = useKeyModifier('CapsLock');
|
||||
* watch(capsLock, (on) => {
|
||||
* if (on) showCapsLockWarning();
|
||||
* });
|
||||
*
|
||||
* @example
|
||||
* const shift = useKeyModifier('Shift', { initial: false, events: ['keydown', 'keyup'] });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useKeyModifier<Initial extends boolean | null = null>(
|
||||
modifier: KeyModifier,
|
||||
options: UseKeyModifierOptions<Initial> = {},
|
||||
): UseKeyModifierReturn<Initial> {
|
||||
const {
|
||||
events = DEFAULT_EVENTS,
|
||||
document = defaultDocument,
|
||||
initial = null,
|
||||
} = options;
|
||||
|
||||
const state = shallowRef(initial) as ShallowRef<boolean | null>;
|
||||
|
||||
if (document) {
|
||||
useEventListener(document, events, (event: KeyboardEvent | MouseEvent) => {
|
||||
if (isFunction(event.getModifierState))
|
||||
state.value = event.getModifierState(modifier);
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
return state as UseKeyModifierReturn<Initial>;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope } from 'vue';
|
||||
import { useLocalFonts } from '.';
|
||||
import type { FontData, QueryLocalFontsOptions } from '.';
|
||||
|
||||
function makeFont(overrides: Partial<FontData> = {}): FontData {
|
||||
return {
|
||||
postscriptName: 'Arial-BoldMT',
|
||||
fullName: 'Arial Bold',
|
||||
family: 'Arial',
|
||||
style: 'Bold',
|
||||
blob: async () => new Blob(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fake `window` exposing `queryLocalFonts`. Passed through options so it
|
||||
* reaches the import-time-captured `defaultWindow` substitute (see test gotcha).
|
||||
*/
|
||||
function createWindow(fonts: FontData[] = [makeFont()]) {
|
||||
const queryLocalFonts = vi.fn(async (_options?: QueryLocalFontsOptions) => fonts);
|
||||
const win = { queryLocalFonts } as unknown as Window & typeof globalThis;
|
||||
|
||||
return { window: win, queryLocalFonts };
|
||||
}
|
||||
|
||||
describe(useLocalFonts, () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('reports supported when queryLocalFonts exists on window', () => {
|
||||
const scope = effectScope();
|
||||
const { window } = createWindow();
|
||||
|
||||
let result: ReturnType<typeof useLocalFonts>;
|
||||
scope.run(() => {
|
||||
result = useLocalFonts({ window });
|
||||
});
|
||||
|
||||
expect(result!.isSupported.value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reports unsupported when the API is absent', () => {
|
||||
const scope = effectScope();
|
||||
const win = {} as unknown as Window & typeof globalThis;
|
||||
|
||||
let result: ReturnType<typeof useLocalFonts>;
|
||||
scope.run(() => {
|
||||
result = useLocalFonts({ window: win });
|
||||
});
|
||||
|
||||
expect(result!.isSupported.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('is SSR safe when window is undefined', async () => {
|
||||
const scope = effectScope();
|
||||
|
||||
let result: ReturnType<typeof useLocalFonts>;
|
||||
scope.run(() => {
|
||||
result = useLocalFonts({ window: undefined as unknown as Window });
|
||||
});
|
||||
|
||||
expect(result!.isSupported.value).toBeFalsy();
|
||||
await expect(result!.query()).resolves.toBeUndefined();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('defaults fonts to an empty array', () => {
|
||||
const scope = effectScope();
|
||||
const { window } = createWindow();
|
||||
|
||||
let result: ReturnType<typeof useLocalFonts>;
|
||||
scope.run(() => {
|
||||
result = useLocalFonts({ window });
|
||||
});
|
||||
|
||||
expect(result!.fonts.value).toEqual([]);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('populates fonts and returns the list on a successful query', async () => {
|
||||
const scope = effectScope();
|
||||
const fonts = [makeFont(), makeFont({ postscriptName: 'Times', fullName: 'Times New Roman' })];
|
||||
const { window, queryLocalFonts } = createWindow(fonts);
|
||||
|
||||
let result: ReturnType<typeof useLocalFonts>;
|
||||
scope.run(() => {
|
||||
result = useLocalFonts({ window });
|
||||
});
|
||||
|
||||
const returned = await result!.query();
|
||||
|
||||
expect(queryLocalFonts).toHaveBeenCalledTimes(1);
|
||||
expect(returned).toEqual(fonts);
|
||||
expect(result!.fonts.value).toEqual(fonts);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('forwards query options to the native API', async () => {
|
||||
const scope = effectScope();
|
||||
const { window, queryLocalFonts } = createWindow();
|
||||
|
||||
let result: ReturnType<typeof useLocalFonts>;
|
||||
scope.run(() => {
|
||||
result = useLocalFonts({ window });
|
||||
});
|
||||
|
||||
await result!.query({ postscriptNames: ['Arial-BoldMT'] });
|
||||
|
||||
expect(queryLocalFonts).toHaveBeenCalledWith({ postscriptNames: ['Arial-BoldMT'] });
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('stores the error and invokes onError when the query rejects', async () => {
|
||||
const scope = effectScope();
|
||||
const error = new DOMException('denied', 'NotAllowedError');
|
||||
const queryLocalFonts = vi.fn(async () => {
|
||||
throw error;
|
||||
});
|
||||
const win = { queryLocalFonts } as unknown as Window & typeof globalThis;
|
||||
const onError = vi.fn();
|
||||
|
||||
let result: ReturnType<typeof useLocalFonts>;
|
||||
scope.run(() => {
|
||||
result = useLocalFonts({ window: win, onError });
|
||||
});
|
||||
|
||||
await expect(result!.query()).resolves.toBeUndefined();
|
||||
expect(onError).toHaveBeenCalledWith(error);
|
||||
expect(result!.error.value).toBe(error);
|
||||
expect(result!.fonts.value).toEqual([]);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('returns undefined and does not call the API when unsupported', async () => {
|
||||
const scope = effectScope();
|
||||
const win = {} as unknown as Window & typeof globalThis;
|
||||
|
||||
let result: ReturnType<typeof useLocalFonts>;
|
||||
scope.run(() => {
|
||||
result = useLocalFonts({ window: win });
|
||||
});
|
||||
|
||||
await expect(result!.query()).resolves.toBeUndefined();
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { shallowRef } from 'vue';
|
||||
import type { ComputedRef, ShallowRef } from 'vue';
|
||||
import { noop } from '@robonen/stdlib';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { useSupported } from '@/composables/utilities/useSupported';
|
||||
import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted';
|
||||
|
||||
/**
|
||||
* A single font face exposed by the [Local Font Access API](https://developer.mozilla.org/en-US/docs/Web/API/FontData).
|
||||
*/
|
||||
export interface FontData {
|
||||
/**
|
||||
* The PostScript name of the font (e.g. `"Arial-BoldMT"`)
|
||||
*/
|
||||
readonly postscriptName: string;
|
||||
|
||||
/**
|
||||
* The full, human-readable name of the font (e.g. `"Arial Bold"`)
|
||||
*/
|
||||
readonly fullName: string;
|
||||
|
||||
/**
|
||||
* The font family (e.g. `"Arial"`)
|
||||
*/
|
||||
readonly family: string;
|
||||
|
||||
/**
|
||||
* The font style/subfamily (e.g. `"Bold"`)
|
||||
*/
|
||||
readonly style: string;
|
||||
|
||||
/**
|
||||
* Resolve the raw font file as a `Blob` (the full SFNT binary)
|
||||
*/
|
||||
blob: () => Promise<Blob>;
|
||||
}
|
||||
|
||||
export interface QueryLocalFontsOptions {
|
||||
/**
|
||||
* Restrict the results to the fonts whose PostScript names appear in this list.
|
||||
*/
|
||||
postscriptNames?: string[];
|
||||
}
|
||||
|
||||
interface WindowWithLocalFonts {
|
||||
queryLocalFonts: (options?: QueryLocalFontsOptions) => Promise<FontData[]>;
|
||||
}
|
||||
|
||||
export interface UseLocalFontsOptions extends ConfigurableWindow {
|
||||
/**
|
||||
* Query the local fonts immediately on mount. The Local Font Access API
|
||||
* requires the `local-fonts` permission (which may prompt the user), so this
|
||||
* is disabled by default — call `query()` from a user gesture instead.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
immediate?: boolean;
|
||||
|
||||
/**
|
||||
* Called when a query rejects (e.g. the permission is denied) instead of
|
||||
* throwing. The same value is also stored in the returned `error` ref.
|
||||
*
|
||||
* @default noop
|
||||
*/
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export interface UseLocalFontsReturn {
|
||||
/**
|
||||
* Whether the [Local Font Access API](https://developer.mozilla.org/en-US/docs/Web/API/Local_Font_Access_API) is supported
|
||||
*/
|
||||
isSupported: ComputedRef<boolean>;
|
||||
|
||||
/**
|
||||
* The fonts returned by the most recent successful `query()`
|
||||
*/
|
||||
fonts: ShallowRef<FontData[]>;
|
||||
|
||||
/**
|
||||
* The last error thrown by `query()`, or `null`
|
||||
*/
|
||||
error: ShallowRef<unknown | null>;
|
||||
|
||||
/**
|
||||
* Enumerate the locally installed fonts. Resolves with the font list, or
|
||||
* `undefined` when the API is unsupported. Pass `postscriptNames` to filter.
|
||||
*/
|
||||
query: (queryOptions?: QueryLocalFontsOptions) => Promise<FontData[] | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useLocalFonts
|
||||
* @category Browser
|
||||
* @description Reactive wrapper around the [Local Font Access API](https://developer.mozilla.org/en-US/docs/Web/API/Local_Font_Access_API) for enumerating the user's locally installed fonts.
|
||||
*
|
||||
* @param {UseLocalFontsOptions} [options={}] Options
|
||||
* @param {boolean} [options.immediate=false] Query immediately on mount (requires the `local-fonts` permission)
|
||||
* @param {Function} [options.onError=noop] Error callback invoked instead of throwing
|
||||
* @param {Window} [options.window=defaultWindow] Custom `window` instance
|
||||
* @returns {UseLocalFontsReturn} `isSupported`, `fonts`, `error`, and `query()`
|
||||
*
|
||||
* @example
|
||||
* const { isSupported, fonts, query } = useLocalFonts();
|
||||
* // Call from a click handler so the permission prompt is allowed
|
||||
* async function pickFonts() {
|
||||
* await query();
|
||||
* console.log(fonts.value.map(font => font.fullName));
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
* // Query only specific fonts by PostScript name
|
||||
* const { fonts, query } = useLocalFonts();
|
||||
* await query({ postscriptNames: ['Arial-BoldMT'] });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useLocalFonts(options: UseLocalFontsOptions = {}): UseLocalFontsReturn {
|
||||
const {
|
||||
window = defaultWindow,
|
||||
immediate = false,
|
||||
onError = noop,
|
||||
} = options;
|
||||
|
||||
const isSupported = useSupported(() => !!window && 'queryLocalFonts' in window);
|
||||
const fonts = shallowRef<FontData[]>([]);
|
||||
const error = shallowRef<unknown | null>(null);
|
||||
|
||||
async function query(queryOptions?: QueryLocalFontsOptions): Promise<FontData[] | undefined> {
|
||||
if (!isSupported.value || !window)
|
||||
return undefined;
|
||||
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const result = await (window as unknown as WindowWithLocalFonts).queryLocalFonts(queryOptions);
|
||||
fonts.value = result;
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (err) {
|
||||
error.value = err;
|
||||
onError(err);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (immediate)
|
||||
tryOnMounted(() => query());
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
fonts,
|
||||
error,
|
||||
query,
|
||||
};
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick, toValue, watch } from 'vue';
|
||||
import { DefaultMagicKeysAliasMap, useMagicKeys } from '.';
|
||||
|
||||
function keydown(key: string, init: KeyboardEventInit = {}, target: EventTarget = globalThis) {
|
||||
target.dispatchEvent(new KeyboardEvent('keydown', { key, ...init }));
|
||||
}
|
||||
|
||||
function keyup(key: string, init: KeyboardEventInit = {}, target: EventTarget = globalThis) {
|
||||
target.dispatchEvent(new KeyboardEvent('keyup', { key, ...init }));
|
||||
}
|
||||
|
||||
describe(useMagicKeys, () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('tracks a single pressed key', () => {
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
const keys = useMagicKeys();
|
||||
|
||||
expect(keys.a!.value).toBeFalsy();
|
||||
|
||||
keydown('a');
|
||||
expect(keys.a!.value).toBeTruthy();
|
||||
|
||||
keyup('a');
|
||||
expect(keys.a!.value).toBeFalsy();
|
||||
});
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('exposes the current Set of pressed keys', () => {
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
const keys = useMagicKeys();
|
||||
|
||||
keydown('a');
|
||||
keydown('b');
|
||||
|
||||
expect([...keys.current]).toEqual(['a', 'b']);
|
||||
|
||||
keyup('a');
|
||||
expect([...keys.current]).toEqual(['b']);
|
||||
});
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('supports combinations via proxy property (ctrl+a)', () => {
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
const keys = useMagicKeys();
|
||||
const ctrlA = keys['ctrl+a']!;
|
||||
|
||||
expect(ctrlA.value).toBeFalsy();
|
||||
|
||||
keydown('Control');
|
||||
expect(ctrlA.value).toBeFalsy();
|
||||
|
||||
keydown('a');
|
||||
expect(ctrlA.value).toBeTruthy();
|
||||
|
||||
keyup('a');
|
||||
expect(ctrlA.value).toBeFalsy();
|
||||
});
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('supports combos with _ and - delimiters', () => {
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
const keys = useMagicKeys();
|
||||
const underscore = keys.ctrl_a!;
|
||||
const dash = keys['ctrl-a']!;
|
||||
|
||||
keydown('Control');
|
||||
keydown('a');
|
||||
|
||||
expect(underscore.value).toBeTruthy();
|
||||
expect(dash.value).toBeTruthy();
|
||||
});
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('resolves aliases (cmd -> meta, esc -> escape)', () => {
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
const keys = useMagicKeys();
|
||||
|
||||
keydown('Meta');
|
||||
expect(keys.cmd!.value).toBeTruthy();
|
||||
expect(keys.command!.value).toBeTruthy();
|
||||
expect(keys.meta!.value).toBeTruthy();
|
||||
|
||||
keyup('Meta');
|
||||
|
||||
keydown('Escape');
|
||||
expect(keys.esc!.value).toBeTruthy();
|
||||
});
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('respects a custom aliasMap', () => {
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
const keys = useMagicKeys({ aliasMap: { fire: 'f' } });
|
||||
|
||||
keydown('f');
|
||||
expect(keys.fire!.value).toBeTruthy();
|
||||
});
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('is case-insensitive on property access', () => {
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
const keys = useMagicKeys();
|
||||
|
||||
keydown('a');
|
||||
expect(keys.A!.value).toBeTruthy();
|
||||
});
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('tracks event.code as well as key', () => {
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
const keys = useMagicKeys();
|
||||
|
||||
keydown('a', { code: 'KeyA' });
|
||||
expect((keys as any).keya.value).toBeTruthy();
|
||||
|
||||
keyup('a', { code: 'KeyA' });
|
||||
expect((keys as any).keya.value).toBeFalsy();
|
||||
});
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reactive mode returns plain booleans', () => {
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
const keys = useMagicKeys({ reactive: true });
|
||||
|
||||
expect(keys.a).toBeFalsy();
|
||||
|
||||
keydown('a');
|
||||
expect(keys.a).toBeTruthy();
|
||||
});
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reset() clears the current Set and all refs', () => {
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
const keys = useMagicKeys();
|
||||
|
||||
keydown('a');
|
||||
keydown('b');
|
||||
expect(keys.a!.value).toBeTruthy();
|
||||
|
||||
keys.reset();
|
||||
|
||||
expect(keys.a!.value).toBeFalsy();
|
||||
expect(keys.b!.value).toBeFalsy();
|
||||
expect(keys.current.size).toBe(0);
|
||||
});
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('resets on blur so keys do not stick', () => {
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
const keys = useMagicKeys();
|
||||
|
||||
keydown('a');
|
||||
expect(keys.a!.value).toBeTruthy();
|
||||
|
||||
globalThis.dispatchEvent(new Event('blur'));
|
||||
expect(keys.a!.value).toBeFalsy();
|
||||
expect(keys.current.size).toBe(0);
|
||||
});
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('clears meta-dependent keys when Meta is released (macOS behaviour)', () => {
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
const keys = useMagicKeys();
|
||||
|
||||
// Press Cmd, then 'a' while Cmd held (getModifierState reports Meta)
|
||||
keydown('Meta');
|
||||
keydown('a', { metaKey: true });
|
||||
|
||||
expect(keys.a!.value).toBeTruthy();
|
||||
expect(keys.current.has('a')).toBeTruthy();
|
||||
|
||||
// Releasing Meta should drop 'a' too (no keyup fires for it on macOS)
|
||||
keyup('Meta');
|
||||
|
||||
expect(keys.meta!.value).toBeFalsy();
|
||||
expect(keys.a!.value).toBeFalsy();
|
||||
expect(keys.current.has('a')).toBeFalsy();
|
||||
});
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('invokes onEventFired for keydown and keyup', () => {
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
const onEventFired = vi.fn();
|
||||
useMagicKeys({ onEventFired });
|
||||
|
||||
keydown('a');
|
||||
keyup('a');
|
||||
|
||||
expect(onEventFired).toHaveBeenCalledTimes(2);
|
||||
expect(onEventFired.mock.calls[0]![0]).toBeInstanceOf(KeyboardEvent);
|
||||
});
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('attaches to a custom target', () => {
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
const el = document.createElement('div');
|
||||
const keys = useMagicKeys({ target: el });
|
||||
|
||||
keydown('a', {}, el);
|
||||
expect(keys.a!.value).toBeTruthy();
|
||||
|
||||
// window events should not affect a custom target
|
||||
keyup('a', {}, el);
|
||||
keydown('b');
|
||||
expect(keys.b!.value).toBeFalsy();
|
||||
});
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('combination refs are reactive (computed)', async () => {
|
||||
const scope = effectScope();
|
||||
|
||||
await scope.run(async () => {
|
||||
const keys = useMagicKeys();
|
||||
const combo = keys['shift+a']!;
|
||||
|
||||
const seen: boolean[] = [];
|
||||
watch(combo, v => seen.push(v));
|
||||
|
||||
keydown('Shift');
|
||||
keydown('a');
|
||||
await nextTick();
|
||||
|
||||
expect(toValue(combo)).toBeTruthy();
|
||||
expect(seen).toContain(true);
|
||||
});
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('exposes DefaultMagicKeysAliasMap with expected aliases', () => {
|
||||
expect(DefaultMagicKeysAliasMap.ctrl).toBe('control');
|
||||
expect(DefaultMagicKeysAliasMap.cmd).toBe('meta');
|
||||
expect(DefaultMagicKeysAliasMap.command).toBe('meta');
|
||||
expect(DefaultMagicKeysAliasMap.option).toBe('alt');
|
||||
expect(DefaultMagicKeysAliasMap.up).toBe('arrowup');
|
||||
});
|
||||
|
||||
it('does not throw and returns an object when window is absent (SSR path)', () => {
|
||||
// No target available -> listeners are skipped, refs still usable
|
||||
const keys = useMagicKeys({ target: undefined as unknown as EventTarget });
|
||||
|
||||
expect(keys.current.size).toBe(0);
|
||||
// accessing a key lazily creates a ref defaulting to false
|
||||
expect(keys.a!.value).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -1,227 +0,0 @@
|
||||
import type { AnyFunction } from '@robonen/stdlib';
|
||||
import { isFunction, noop } from '@robonen/stdlib';
|
||||
import type { ComputedRef, Ref, ShallowRef } from 'vue';
|
||||
import { computed, reactive, shallowRef, toValue } from 'vue';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
import { defaultWindow } from '@/types';
|
||||
|
||||
export type UseMagicKeysAliasMap = Readonly<Record<string, string>>;
|
||||
|
||||
/**
|
||||
* Default lowercase alias map: maps common shorthand key names to their
|
||||
* canonical `KeyboardEvent.key` (lowercased) equivalents.
|
||||
*/
|
||||
export const DefaultMagicKeysAliasMap: UseMagicKeysAliasMap = /* #__PURE__ */ {
|
||||
ctrl: 'control',
|
||||
command: 'meta',
|
||||
cmd: 'meta',
|
||||
option: 'alt',
|
||||
opt: 'alt',
|
||||
up: 'arrowup',
|
||||
down: 'arrowdown',
|
||||
left: 'arrowleft',
|
||||
right: 'arrowright',
|
||||
esc: 'escape',
|
||||
space: ' ',
|
||||
};
|
||||
|
||||
export interface UseMagicKeysOptions<Reactive extends boolean> {
|
||||
/**
|
||||
* Return a reactive object instead of an object of refs
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
reactive?: Reactive;
|
||||
|
||||
/**
|
||||
* Event target to attach the keyboard listeners to
|
||||
*
|
||||
* @default window
|
||||
*/
|
||||
target?: EventTarget;
|
||||
|
||||
/**
|
||||
* Alias map for keys, all keys should be lowercase
|
||||
* { foo: 'bar' } means that pressing `bar` will also trigger `foo`
|
||||
*
|
||||
* @default DefaultMagicKeysAliasMap
|
||||
*/
|
||||
aliasMap?: UseMagicKeysAliasMap;
|
||||
|
||||
/**
|
||||
* Register passive listeners
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
passive?: boolean;
|
||||
|
||||
/**
|
||||
* Custom event handler for the keyboard event. Useful for preventing default
|
||||
* behaviour for certain key combos. Called on every keydown and keyup.
|
||||
*/
|
||||
onEventFired?: (event: KeyboardEvent) => void | boolean;
|
||||
}
|
||||
|
||||
export interface UseMagicKeysReturn {
|
||||
/**
|
||||
* A Set of currently pressed keys (lowercase canonical names)
|
||||
*/
|
||||
current: Set<string>;
|
||||
|
||||
/**
|
||||
* Reset all tracked keys to `false` and clear the current Set
|
||||
*/
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export type MagicKeys<Reactive extends boolean> = Readonly<
|
||||
Omit<
|
||||
Record<string, Reactive extends true ? boolean : ComputedRef<boolean>>,
|
||||
keyof UseMagicKeysReturn
|
||||
>
|
||||
& UseMagicKeysReturn
|
||||
>;
|
||||
|
||||
type KeyRefs = Record<string, Ref<boolean> | ShallowRef<boolean> | ComputedRef<boolean>>;
|
||||
|
||||
/**
|
||||
* @name useMagicKeys
|
||||
* @category Browser
|
||||
* @description Reactive keys pressed state, with magical combination keys support via a Proxy.
|
||||
* Access combinations directly as properties, e.g. `keys['ctrl+a']` or `keys.ctrl_a`.
|
||||
*
|
||||
* @param {UseMagicKeysOptions} [options] Configuration options
|
||||
* @returns {MagicKeys} A Proxy of refs (or reactive booleans) plus `current` Set and `reset`
|
||||
*
|
||||
* @example
|
||||
* const keys = useMagicKeys();
|
||||
* const ctrlA = keys['ctrl+a'];
|
||||
* watch(ctrlA, v => { if (v) console.log('Ctrl + A pressed'); });
|
||||
*
|
||||
* @example
|
||||
* const { ctrl, a, current } = useMagicKeys({ reactive: true });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useMagicKeys(options?: UseMagicKeysOptions<false>): MagicKeys<false>;
|
||||
export function useMagicKeys(options: UseMagicKeysOptions<true>): MagicKeys<true>;
|
||||
export function useMagicKeys(options: UseMagicKeysOptions<boolean> = {}): any {
|
||||
const {
|
||||
reactive: useReactive = false,
|
||||
target = defaultWindow,
|
||||
aliasMap = DefaultMagicKeysAliasMap,
|
||||
passive = true,
|
||||
onEventFired = noop as AnyFunction,
|
||||
} = options;
|
||||
|
||||
const current = reactive(new Set<string>());
|
||||
const usedKeys = new Set<string>();
|
||||
// Keys pressed while Meta is held — on macOS, keyup is suppressed for other
|
||||
// keys while Cmd is down, so we clear them when Meta is released.
|
||||
const metaDeps = new Set<string>();
|
||||
|
||||
function reset(): void {
|
||||
current.clear();
|
||||
|
||||
for (const key of usedKeys)
|
||||
setRefs(key, false);
|
||||
}
|
||||
|
||||
const obj: UseMagicKeysReturn = {
|
||||
current,
|
||||
reset,
|
||||
};
|
||||
|
||||
const refs: KeyRefs = useReactive ? reactive(obj as any) : (obj as any);
|
||||
|
||||
function setRefs(key: string, value: boolean): void {
|
||||
// Touch the proxy so the ref is materialized for keys we actually track,
|
||||
// even if the consumer hasn't accessed them yet.
|
||||
if (!(key in refs))
|
||||
void (proxy as any)[key];
|
||||
|
||||
if (key in refs) {
|
||||
if (useReactive)
|
||||
(refs as any)[key] = value;
|
||||
else
|
||||
(refs[key] as Ref<boolean>).value = value;
|
||||
}
|
||||
}
|
||||
|
||||
function updateRefs(event: KeyboardEvent, value: boolean): void {
|
||||
const key = event.key?.toLowerCase();
|
||||
const code = event.code?.toLowerCase();
|
||||
const values = [code, key].filter(Boolean) as string[];
|
||||
|
||||
if (!key)
|
||||
return;
|
||||
|
||||
if (value)
|
||||
current.add(key);
|
||||
else
|
||||
current.delete(key);
|
||||
|
||||
for (const k of values) {
|
||||
usedKeys.add(k);
|
||||
setRefs(k, value);
|
||||
}
|
||||
|
||||
if (key === 'meta' && !value) {
|
||||
// Cmd released on macOS: clear keys that were pressed during the chord
|
||||
metaDeps.forEach((k) => {
|
||||
current.delete(k);
|
||||
setRefs(k, false);
|
||||
});
|
||||
|
||||
metaDeps.clear();
|
||||
}
|
||||
else if (isFunction(event.getModifierState) && event.getModifierState('Meta') && value) {
|
||||
[...current, ...values].forEach(k => metaDeps.add(k));
|
||||
}
|
||||
}
|
||||
|
||||
if (target) {
|
||||
useEventListener(target, 'keydown', (event: KeyboardEvent) => {
|
||||
updateRefs(event, true);
|
||||
return onEventFired(event);
|
||||
}, { passive });
|
||||
|
||||
useEventListener(target, 'keyup', (event: KeyboardEvent) => {
|
||||
updateRefs(event, false);
|
||||
return onEventFired(event);
|
||||
}, { passive });
|
||||
|
||||
// Reset on blur so keys don't "stick" when focus leaves the page
|
||||
useEventListener('blur', reset, { passive: true });
|
||||
useEventListener('focus', reset, { passive: true });
|
||||
}
|
||||
|
||||
const proxy = new Proxy(refs, {
|
||||
get(target, prop, receiver) {
|
||||
if (typeof prop !== 'string')
|
||||
return Reflect.get(target, prop, receiver);
|
||||
|
||||
prop = prop.toLowerCase();
|
||||
|
||||
// alias resolution
|
||||
if (prop in aliasMap)
|
||||
prop = aliasMap[prop] as string;
|
||||
|
||||
// lazily create tracking ref for combos and single keys
|
||||
if (!(prop in refs)) {
|
||||
if (/[+_-]/.test(prop)) {
|
||||
const keys = prop.split(/[+_-]/g).map((i: string) => i.trim());
|
||||
refs[prop] = computed(() => keys.map(key => toValue((proxy as any)[key])).every(Boolean));
|
||||
}
|
||||
else {
|
||||
refs[prop] = shallowRef(false);
|
||||
}
|
||||
}
|
||||
|
||||
const r = Reflect.get(target, prop, receiver);
|
||||
return useReactive ? toValue(r) : r;
|
||||
},
|
||||
});
|
||||
|
||||
return proxy as any;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { ComputedRef, MaybeRefOrGetter } from 'vue';
|
||||
import { isFunction, isNumber } from '@robonen/stdlib';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { useSupported } from '@/composables/browser/useSupported';
|
||||
import { useSupported } from '@/composables/utilities/useSupported';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
|
||||
export interface UseMediaQueryOptions extends ConfigurableWindow {
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick, shallowRef } from 'vue';
|
||||
import { useMouse } from '.';
|
||||
|
||||
function dispatchMouseMove(target: EventTarget, coords: Partial<Record<'pageX' | 'pageY' | 'clientX' | 'clientY' | 'screenX' | 'screenY' | 'movementX' | 'movementY', number>>) {
|
||||
const event = new MouseEvent('mousemove');
|
||||
for (const [key, value] of Object.entries(coords))
|
||||
Object.defineProperty(event, key, { value, configurable: true });
|
||||
target.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
describe(useMouse, () => {
|
||||
it('uses the initial value', () => {
|
||||
const scope = effectScope();
|
||||
let mouse: ReturnType<typeof useMouse>;
|
||||
scope.run(() => {
|
||||
mouse = useMouse({ initialValue: { x: 5, y: 10 } });
|
||||
});
|
||||
expect(mouse!.x.value).toBe(5);
|
||||
expect(mouse!.y.value).toBe(10);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('tracks mousemove with page coordinates', async () => {
|
||||
const scope = effectScope();
|
||||
let mouse: ReturnType<typeof useMouse>;
|
||||
scope.run(() => {
|
||||
mouse = useMouse();
|
||||
});
|
||||
|
||||
dispatchMouseMove(globalThis, { pageX: 100, pageY: 200 });
|
||||
await nextTick();
|
||||
|
||||
expect(mouse!.x.value).toBe(100);
|
||||
expect(mouse!.y.value).toBe(200);
|
||||
expect(mouse!.sourceType.value).toBe('mouse');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('supports client coordinate type', async () => {
|
||||
const scope = effectScope();
|
||||
let mouse: ReturnType<typeof useMouse>;
|
||||
scope.run(() => {
|
||||
mouse = useMouse({ type: 'client' });
|
||||
});
|
||||
|
||||
const event = new MouseEvent('mousemove', { clientX: 7, clientY: 9 });
|
||||
globalThis.dispatchEvent(event);
|
||||
await nextTick();
|
||||
|
||||
expect(mouse!.x.value).toBe(7);
|
||||
expect(mouse!.y.value).toBe(9);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('supports screen coordinate type', async () => {
|
||||
const scope = effectScope();
|
||||
let mouse: ReturnType<typeof useMouse>;
|
||||
scope.run(() => {
|
||||
mouse = useMouse({ type: 'screen' });
|
||||
});
|
||||
|
||||
const event = new MouseEvent('mousemove', { screenX: 11, screenY: 22 });
|
||||
globalThis.dispatchEvent(event);
|
||||
await nextTick();
|
||||
|
||||
expect(mouse!.x.value).toBe(11);
|
||||
expect(mouse!.y.value).toBe(22);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('supports a custom extractor function', async () => {
|
||||
const scope = effectScope();
|
||||
let mouse: ReturnType<typeof useMouse>;
|
||||
scope.run(() => {
|
||||
mouse = useMouse({ type: e => [(e as MouseEvent).pageX * 2, (e as MouseEvent).pageY * 2] });
|
||||
});
|
||||
|
||||
dispatchMouseMove(globalThis, { pageX: 3, pageY: 4 });
|
||||
await nextTick();
|
||||
|
||||
expect(mouse!.x.value).toBe(6);
|
||||
expect(mouse!.y.value).toBe(8);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not update when the extractor returns null', async () => {
|
||||
const scope = effectScope();
|
||||
let mouse: ReturnType<typeof useMouse>;
|
||||
// a custom extractor that opts out of updating
|
||||
scope.run(() => {
|
||||
mouse = useMouse({ type: () => null, initialValue: { x: 1, y: 2 } });
|
||||
});
|
||||
|
||||
dispatchMouseMove(globalThis, { pageX: 99, pageY: 99 });
|
||||
await nextTick();
|
||||
|
||||
expect(mouse!.x.value).toBe(1);
|
||||
expect(mouse!.y.value).toBe(2);
|
||||
expect(mouse!.sourceType.value).toBe(null);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('tracks touch events', async () => {
|
||||
const scope = effectScope();
|
||||
let mouse: ReturnType<typeof useMouse>;
|
||||
scope.run(() => {
|
||||
mouse = useMouse();
|
||||
});
|
||||
|
||||
const touch = { clientX: 0, clientY: 0, pageX: 50, pageY: 60 } as Touch;
|
||||
const event = new Event('touchstart') as TouchEvent;
|
||||
Object.defineProperty(event, 'touches', { value: [touch], configurable: true });
|
||||
globalThis.dispatchEvent(event);
|
||||
await nextTick();
|
||||
|
||||
expect(mouse!.x.value).toBe(50);
|
||||
expect(mouse!.y.value).toBe(60);
|
||||
expect(mouse!.sourceType.value).toBe('touch');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('ignores touch events when touch is disabled', async () => {
|
||||
const scope = effectScope();
|
||||
let mouse: ReturnType<typeof useMouse>;
|
||||
scope.run(() => {
|
||||
mouse = useMouse({ touch: false, initialValue: { x: 1, y: 1 } });
|
||||
});
|
||||
|
||||
const touch = { pageX: 50, pageY: 60 } as Touch;
|
||||
const event = new Event('touchstart') as TouchEvent;
|
||||
Object.defineProperty(event, 'touches', { value: [touch], configurable: true });
|
||||
globalThis.dispatchEvent(event);
|
||||
await nextTick();
|
||||
|
||||
expect(mouse!.x.value).toBe(1);
|
||||
expect(mouse!.y.value).toBe(1);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('resets on touchend when resetOnTouchEnds is set', async () => {
|
||||
const scope = effectScope();
|
||||
let mouse: ReturnType<typeof useMouse>;
|
||||
scope.run(() => {
|
||||
mouse = useMouse({ resetOnTouchEnds: true, initialValue: { x: 9, y: 9 } });
|
||||
});
|
||||
|
||||
const touch = { pageX: 50, pageY: 60 } as Touch;
|
||||
const start = new Event('touchstart') as TouchEvent;
|
||||
Object.defineProperty(start, 'touches', { value: [touch], configurable: true });
|
||||
globalThis.dispatchEvent(start);
|
||||
await nextTick();
|
||||
expect(mouse!.x.value).toBe(50);
|
||||
|
||||
globalThis.dispatchEvent(new Event('touchend'));
|
||||
await nextTick();
|
||||
expect(mouse!.x.value).toBe(9);
|
||||
expect(mouse!.y.value).toBe(9);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('updates page coordinates on scroll without pointer movement', async () => {
|
||||
const scope = effectScope();
|
||||
let mouse: ReturnType<typeof useMouse>;
|
||||
scope.run(() => {
|
||||
mouse = useMouse({ type: 'page' });
|
||||
});
|
||||
|
||||
// record a mouse position while scrollX/scrollY are 0
|
||||
(globalThis as any).scrollX = 0;
|
||||
(globalThis as any).scrollY = 0;
|
||||
dispatchMouseMove(globalThis, { pageX: 100, pageY: 100 });
|
||||
await nextTick();
|
||||
expect(mouse!.x.value).toBe(100);
|
||||
|
||||
// scroll the page; page coordinates should shift by the scroll delta
|
||||
(globalThis as any).scrollX = 30;
|
||||
(globalThis as any).scrollY = 40;
|
||||
globalThis.dispatchEvent(new Event('scroll'));
|
||||
await nextTick();
|
||||
|
||||
expect(mouse!.x.value).toBe(130);
|
||||
expect(mouse!.y.value).toBe(140);
|
||||
|
||||
(globalThis as any).scrollX = 0;
|
||||
(globalThis as any).scrollY = 0;
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not register a scroll listener for non-page types', async () => {
|
||||
const addSpy = vi.spyOn(globalThis, 'addEventListener');
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
useMouse({ type: 'client' });
|
||||
});
|
||||
|
||||
const scrollCalls = addSpy.mock.calls.filter(([name]) => name === 'scroll');
|
||||
expect(scrollCalls).toHaveLength(0);
|
||||
|
||||
addSpy.mockRestore();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('attaches passive listeners', () => {
|
||||
const addSpy = vi.spyOn(globalThis, 'addEventListener');
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
useMouse();
|
||||
});
|
||||
|
||||
const moveCall = addSpy.mock.calls.find(([name]) => name === 'mousemove');
|
||||
expect(moveCall).toBeDefined();
|
||||
expect((moveCall![2] as AddEventListenerOptions).passive).toBeTruthy();
|
||||
|
||||
addSpy.mockRestore();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('listens on a custom element target', async () => {
|
||||
const el = document.createElement('div');
|
||||
const elRef = shallowRef(el);
|
||||
const addSpy = vi.spyOn(el, 'addEventListener');
|
||||
|
||||
const scope = effectScope();
|
||||
let mouse: ReturnType<typeof useMouse>;
|
||||
scope.run(() => {
|
||||
mouse = useMouse({ target: elRef, type: 'client' });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(addSpy.mock.calls.some(([name]) => name === 'mousemove')).toBeTruthy();
|
||||
|
||||
const event = new MouseEvent('mousemove', { clientX: 5, clientY: 6 });
|
||||
el.dispatchEvent(event);
|
||||
await nextTick();
|
||||
|
||||
expect(mouse!.x.value).toBe(5);
|
||||
expect(mouse!.y.value).toBe(6);
|
||||
|
||||
addSpy.mockRestore();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does nothing when window is unavailable (SSR)', () => {
|
||||
const scope = effectScope();
|
||||
let mouse: ReturnType<typeof useMouse>;
|
||||
scope.run(() => {
|
||||
mouse = useMouse({ window: undefined, target: undefined });
|
||||
});
|
||||
expect(mouse!.x.value).toBe(0);
|
||||
expect(mouse!.y.value).toBe(0);
|
||||
expect(mouse!.sourceType.value).toBe(null);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('applies an event filter (throttle drops intermediate moves)', async () => {
|
||||
vi.useFakeTimers();
|
||||
const filtered = vi.fn();
|
||||
// simple leading-only filter: only the first invoke passes immediately
|
||||
let used = false;
|
||||
const onceFilter = (invoke: () => void) => {
|
||||
if (!used) {
|
||||
used = true;
|
||||
invoke();
|
||||
}
|
||||
};
|
||||
|
||||
const scope = effectScope();
|
||||
let mouse: ReturnType<typeof useMouse>;
|
||||
scope.run(() => {
|
||||
mouse = useMouse({ eventFilter: onceFilter });
|
||||
});
|
||||
|
||||
dispatchMouseMove(globalThis, { pageX: 10, pageY: 10 });
|
||||
dispatchMouseMove(globalThis, { pageX: 20, pageY: 20 });
|
||||
await nextTick();
|
||||
|
||||
// only the first move was let through
|
||||
expect(mouse!.x.value).toBe(10);
|
||||
expect(mouse!.y.value).toBe(10);
|
||||
|
||||
filtered();
|
||||
scope.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -1,204 +0,0 @@
|
||||
import { shallowRef } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import { isFunction } from '@robonen/stdlib';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
import { unrefElement } from '@/composables/component/unrefElement';
|
||||
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
||||
import { bypassFilter, createFilterWrapper } from '@/utils/filters';
|
||||
import type { ConfigurableEventFilter } from '@/utils/filters';
|
||||
|
||||
export type UseMouseCoordType = 'page' | 'client' | 'screen' | 'movement';
|
||||
export type UseMouseSourceType = 'mouse' | 'touch' | null;
|
||||
|
||||
/**
|
||||
* Extracts an `[x, y]` pair from a mouse or touch point, or `null` to skip.
|
||||
*/
|
||||
export type UseMouseEventExtractor = (event: MouseEvent | Touch) => [x: number, y: number] | null | undefined;
|
||||
|
||||
export interface UseMouseOptions extends ConfigurableWindow, ConfigurableEventFilter {
|
||||
/**
|
||||
* Which coordinate pair to read from the event, or a custom extractor function
|
||||
*
|
||||
* @default 'page'
|
||||
*/
|
||||
type?: UseMouseCoordType | UseMouseEventExtractor;
|
||||
|
||||
/**
|
||||
* Target to attach the listeners to. Accepts a window, document, element ref,
|
||||
* getter, or component instance.
|
||||
*
|
||||
* @default window
|
||||
*/
|
||||
target?: MaybeComputedElementRef | Window | Document;
|
||||
|
||||
/**
|
||||
* Listen to touch events
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
touch?: boolean;
|
||||
|
||||
/**
|
||||
* Track window scroll so that `page` coordinates stay accurate while the page
|
||||
* scrolls without the pointer moving. Only applies when `type === 'page'`.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
scroll?: boolean;
|
||||
|
||||
/**
|
||||
* Reset coordinates to `initialValue` on `touchend`
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
resetOnTouchEnds?: boolean;
|
||||
|
||||
/**
|
||||
* Initial coordinates
|
||||
*
|
||||
* @default { x: 0, y: 0 }
|
||||
*/
|
||||
initialValue?: { x: number; y: number };
|
||||
}
|
||||
|
||||
export interface UseMouseReturn {
|
||||
x: Ref<number>;
|
||||
y: Ref<number>;
|
||||
sourceType: Ref<UseMouseSourceType>;
|
||||
}
|
||||
|
||||
const builtinExtractors: Record<UseMouseCoordType, UseMouseEventExtractor> = {
|
||||
page: e => [(e as MouseEvent).pageX, (e as MouseEvent).pageY],
|
||||
client: e => [e.clientX, e.clientY],
|
||||
screen: e => [e.screenX, e.screenY],
|
||||
movement: e => ('movementX' in e ? [e.movementX, e.movementY] : null),
|
||||
};
|
||||
|
||||
/**
|
||||
* @name useMouse
|
||||
* @category Browser
|
||||
* @description Reactive mouse (and optionally touch) position with optional
|
||||
* custom target, scroll tracking, custom extractors, and event filtering.
|
||||
*
|
||||
* @param {UseMouseOptions} [options={}] Options
|
||||
* @returns {UseMouseReturn} Reactive `x`, `y`, and `sourceType`
|
||||
*
|
||||
* @example
|
||||
* const { x, y, sourceType } = useMouse();
|
||||
*
|
||||
* @example
|
||||
* // Track relative to an element, throttled
|
||||
* const { x, y } = useMouse({ target: el, eventFilter: throttleFilter(50) });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useMouse(options: UseMouseOptions = {}): UseMouseReturn {
|
||||
const {
|
||||
type = 'page',
|
||||
touch = true,
|
||||
scroll = true,
|
||||
resetOnTouchEnds = false,
|
||||
initialValue = { x: 0, y: 0 },
|
||||
window = defaultWindow,
|
||||
target = window,
|
||||
eventFilter,
|
||||
} = options;
|
||||
|
||||
let prevMouseEvent: MouseEvent | null = null;
|
||||
let prevScrollX = 0;
|
||||
let prevScrollY = 0;
|
||||
|
||||
const x = shallowRef(initialValue.x);
|
||||
const y = shallowRef(initialValue.y);
|
||||
const sourceType = shallowRef<UseMouseSourceType>(null);
|
||||
|
||||
const isExtractorFn = isFunction(type);
|
||||
const extractor: UseMouseEventExtractor = isExtractorFn ? type : builtinExtractors[type];
|
||||
|
||||
const mouseHandler = (event: MouseEvent) => {
|
||||
const result = extractor(event);
|
||||
prevMouseEvent = event;
|
||||
|
||||
if (result) {
|
||||
[x.value, y.value] = result;
|
||||
sourceType.value = 'mouse';
|
||||
}
|
||||
|
||||
if (window) {
|
||||
prevScrollX = window.scrollX;
|
||||
prevScrollY = window.scrollY;
|
||||
}
|
||||
};
|
||||
|
||||
const touchHandler = (event: TouchEvent) => {
|
||||
if (event.touches.length) {
|
||||
const result = extractor(event.touches[0]!);
|
||||
|
||||
if (result) {
|
||||
[x.value, y.value] = result;
|
||||
sourceType.value = 'touch';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Keep page coordinates correct when scrolling without moving the pointer.
|
||||
const scrollHandler = () => {
|
||||
if (!prevMouseEvent || !window)
|
||||
return;
|
||||
|
||||
const result = extractor(prevMouseEvent);
|
||||
|
||||
if (result) {
|
||||
x.value = result[0] + window.scrollX - prevScrollX;
|
||||
y.value = result[1] + window.scrollY - prevScrollY;
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
x.value = initialValue.x;
|
||||
y.value = initialValue.y;
|
||||
};
|
||||
|
||||
const filter = eventFilter ?? bypassFilter;
|
||||
const mouseHandlerWrapper = createFilterWrapper(filter, mouseHandler);
|
||||
const touchHandlerWrapper = createFilterWrapper(filter, touchHandler);
|
||||
const scrollHandlerWrapper = createFilterWrapper(filter, scrollHandler);
|
||||
|
||||
const trackTouch = touch && !(isExtractorFn ? false : type === 'movement');
|
||||
const trackScroll = scroll && !!window && (isExtractorFn ? true : type === 'page');
|
||||
|
||||
// A raw window/document/EventTarget is used directly (fast, non-reactive path
|
||||
// in useEventListener). Refs/getters/element instances are resolved lazily via
|
||||
// a getter so the listeners re-bind when the underlying element changes.
|
||||
const listenTarget = isTarget(target)
|
||||
? target
|
||||
: (): EventTarget | null | undefined => unrefElement(target as MaybeComputedElementRef) as EventTarget | null | undefined;
|
||||
|
||||
if (target) {
|
||||
const listenerOptions = { passive: true };
|
||||
|
||||
useEventListener(listenTarget, ['mousemove', 'dragover'], mouseHandlerWrapper as unknown as (e: Event) => void, listenerOptions);
|
||||
|
||||
if (trackTouch) {
|
||||
useEventListener(listenTarget, ['touchstart', 'touchmove'], touchHandlerWrapper as unknown as (e: Event) => void, listenerOptions);
|
||||
|
||||
if (resetOnTouchEnds)
|
||||
useEventListener(listenTarget, 'touchend', reset, listenerOptions);
|
||||
}
|
||||
|
||||
if (trackScroll)
|
||||
useEventListener(window, 'scroll', scrollHandlerWrapper as (e: Event) => void, listenerOptions);
|
||||
}
|
||||
|
||||
return { x, y, sourceType };
|
||||
}
|
||||
|
||||
/**
|
||||
* `true` for an object that is itself an event target (window/document/element)
|
||||
* and should be attached to directly, rather than unwrapped from a ref/getter.
|
||||
*/
|
||||
function isTarget(value: unknown): value is EventTarget {
|
||||
return typeof value === 'object' && value !== null && 'addEventListener' in value;
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick, shallowRef } from 'vue';
|
||||
import { useMousePressed } from '.';
|
||||
|
||||
function dispatch(target: EventTarget, type: string): Event {
|
||||
const event = new Event(type);
|
||||
target.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
describe(useMousePressed, () => {
|
||||
it('starts not pressed by default', () => {
|
||||
const scope = effectScope();
|
||||
let res: ReturnType<typeof useMousePressed>;
|
||||
scope.run(() => {
|
||||
res = useMousePressed();
|
||||
});
|
||||
expect(res!.pressed.value).toBeFalsy();
|
||||
expect(res!.sourceType.value).toBe(null);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('honors the initial value', () => {
|
||||
const scope = effectScope();
|
||||
let res: ReturnType<typeof useMousePressed>;
|
||||
scope.run(() => {
|
||||
res = useMousePressed({ initialValue: true });
|
||||
});
|
||||
expect(res!.pressed.value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('sets pressed and mouse sourceType on mousedown, clears on mouseup', async () => {
|
||||
const scope = effectScope();
|
||||
let res: ReturnType<typeof useMousePressed>;
|
||||
scope.run(() => {
|
||||
res = useMousePressed();
|
||||
});
|
||||
|
||||
dispatch(globalThis, 'mousedown');
|
||||
await nextTick();
|
||||
expect(res!.pressed.value).toBeTruthy();
|
||||
expect(res!.sourceType.value).toBe('mouse');
|
||||
|
||||
dispatch(globalThis, 'mouseup');
|
||||
await nextTick();
|
||||
expect(res!.pressed.value).toBeFalsy();
|
||||
expect(res!.sourceType.value).toBe(null);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('clears pressed on mouseleave', async () => {
|
||||
const scope = effectScope();
|
||||
let res: ReturnType<typeof useMousePressed>;
|
||||
scope.run(() => {
|
||||
res = useMousePressed();
|
||||
});
|
||||
|
||||
dispatch(globalThis, 'mousedown');
|
||||
await nextTick();
|
||||
expect(res!.pressed.value).toBeTruthy();
|
||||
|
||||
dispatch(globalThis, 'mouseleave');
|
||||
await nextTick();
|
||||
expect(res!.pressed.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('tracks touch presses with touch sourceType', async () => {
|
||||
const scope = effectScope();
|
||||
let res: ReturnType<typeof useMousePressed>;
|
||||
scope.run(() => {
|
||||
res = useMousePressed();
|
||||
});
|
||||
|
||||
dispatch(globalThis, 'touchstart');
|
||||
await nextTick();
|
||||
expect(res!.pressed.value).toBeTruthy();
|
||||
expect(res!.sourceType.value).toBe('touch');
|
||||
|
||||
dispatch(globalThis, 'touchend');
|
||||
await nextTick();
|
||||
expect(res!.pressed.value).toBeFalsy();
|
||||
expect(res!.sourceType.value).toBe(null);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('clears pressed on touchcancel', async () => {
|
||||
const scope = effectScope();
|
||||
let res: ReturnType<typeof useMousePressed>;
|
||||
scope.run(() => {
|
||||
res = useMousePressed();
|
||||
});
|
||||
|
||||
dispatch(globalThis, 'touchstart');
|
||||
await nextTick();
|
||||
expect(res!.pressed.value).toBeTruthy();
|
||||
|
||||
dispatch(globalThis, 'touchcancel');
|
||||
await nextTick();
|
||||
expect(res!.pressed.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not register touch listeners when touch is disabled', async () => {
|
||||
const addSpy = vi.spyOn(globalThis, 'addEventListener');
|
||||
const scope = effectScope();
|
||||
let res: ReturnType<typeof useMousePressed>;
|
||||
scope.run(() => {
|
||||
res = useMousePressed({ touch: false });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
const touchCalls = addSpy.mock.calls.filter(([name]) => String(name).startsWith('touch'));
|
||||
expect(touchCalls).toHaveLength(0);
|
||||
|
||||
dispatch(globalThis, 'touchstart');
|
||||
await nextTick();
|
||||
expect(res!.pressed.value).toBeFalsy();
|
||||
|
||||
addSpy.mockRestore();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('tracks drag presses when drag is enabled', async () => {
|
||||
const scope = effectScope();
|
||||
let res: ReturnType<typeof useMousePressed>;
|
||||
scope.run(() => {
|
||||
res = useMousePressed();
|
||||
});
|
||||
|
||||
dispatch(globalThis, 'dragstart');
|
||||
await nextTick();
|
||||
expect(res!.pressed.value).toBeTruthy();
|
||||
expect(res!.sourceType.value).toBe('mouse');
|
||||
|
||||
dispatch(globalThis, 'dragend');
|
||||
await nextTick();
|
||||
expect(res!.pressed.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('clears pressed on drop', async () => {
|
||||
const scope = effectScope();
|
||||
let res: ReturnType<typeof useMousePressed>;
|
||||
scope.run(() => {
|
||||
res = useMousePressed();
|
||||
});
|
||||
|
||||
dispatch(globalThis, 'dragstart');
|
||||
await nextTick();
|
||||
expect(res!.pressed.value).toBeTruthy();
|
||||
|
||||
dispatch(globalThis, 'drop');
|
||||
await nextTick();
|
||||
expect(res!.pressed.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not register drag listeners when drag is disabled', async () => {
|
||||
const addSpy = vi.spyOn(globalThis, 'addEventListener');
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
useMousePressed({ drag: false });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
const dragCalls = addSpy.mock.calls.filter(([name]) => String(name).startsWith('drag') || name === 'drop');
|
||||
expect(dragCalls).toHaveLength(0);
|
||||
|
||||
addSpy.mockRestore();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('invokes onPressed and onReleased callbacks', async () => {
|
||||
const onPressed = vi.fn();
|
||||
const onReleased = vi.fn();
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
useMousePressed({ onPressed, onReleased });
|
||||
});
|
||||
|
||||
const down = dispatch(globalThis, 'mousedown');
|
||||
await nextTick();
|
||||
expect(onPressed).toHaveBeenCalledTimes(1);
|
||||
expect(onPressed).toHaveBeenCalledWith(down);
|
||||
|
||||
const up = dispatch(globalThis, 'mouseup');
|
||||
await nextTick();
|
||||
expect(onReleased).toHaveBeenCalledTimes(1);
|
||||
expect(onReleased).toHaveBeenCalledWith(up);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('attaches passive listeners and respects the capture option', async () => {
|
||||
const addSpy = vi.spyOn(globalThis, 'addEventListener');
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
useMousePressed({ capture: true });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
const downCall = addSpy.mock.calls.find(([name]) => name === 'mousedown');
|
||||
expect(downCall).toBeDefined();
|
||||
const opts = downCall![2] as AddEventListenerOptions;
|
||||
expect(opts.passive).toBeTruthy();
|
||||
expect(opts.capture).toBeTruthy();
|
||||
|
||||
addSpy.mockRestore();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('listens for press on a custom element target', async () => {
|
||||
const el = document.createElement('div');
|
||||
const elRef = shallowRef(el);
|
||||
const addSpy = vi.spyOn(el, 'addEventListener');
|
||||
|
||||
const scope = effectScope();
|
||||
let res: ReturnType<typeof useMousePressed>;
|
||||
scope.run(() => {
|
||||
res = useMousePressed({ target: elRef });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(addSpy.mock.calls.some(([name]) => name === 'mousedown')).toBeTruthy();
|
||||
|
||||
dispatch(el, 'mousedown');
|
||||
await nextTick();
|
||||
expect(res!.pressed.value).toBeTruthy();
|
||||
expect(res!.sourceType.value).toBe('mouse');
|
||||
|
||||
// release still listens on window
|
||||
dispatch(globalThis, 'mouseup');
|
||||
await nextTick();
|
||||
expect(res!.pressed.value).toBeFalsy();
|
||||
|
||||
addSpy.mockRestore();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does nothing when window is unavailable (SSR)', () => {
|
||||
const scope = effectScope();
|
||||
let res: ReturnType<typeof useMousePressed>;
|
||||
scope.run(() => {
|
||||
res = useMousePressed({ window: undefined, initialValue: true });
|
||||
});
|
||||
// returns refs without attaching listeners
|
||||
expect(res!.pressed.value).toBeTruthy();
|
||||
expect(res!.sourceType.value).toBe(null);
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -1,130 +0,0 @@
|
||||
import { computed, shallowRef } from 'vue';
|
||||
import type { ComputedRef, ShallowRef } from 'vue';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
import { unrefElement } from '@/composables/component/unrefElement';
|
||||
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
||||
import type { UseMouseSourceType } from '@/composables/browser/useMouse';
|
||||
|
||||
export type UseMousePressedEvent = MouseEvent | TouchEvent | DragEvent;
|
||||
|
||||
export interface UseMousePressedOptions extends ConfigurableWindow {
|
||||
/**
|
||||
* Listen to `touchstart`, `touchend`, and `touchcancel` events
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
touch?: boolean;
|
||||
|
||||
/**
|
||||
* Listen to `dragstart`, `drop`, and `dragend` events
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
drag?: boolean;
|
||||
|
||||
/**
|
||||
* Add event listeners with the `capture` option set to `true`
|
||||
* (see [MDN](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#capture))
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
capture?: boolean;
|
||||
|
||||
/**
|
||||
* Initial pressed state
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
initialValue?: boolean;
|
||||
|
||||
/**
|
||||
* Element target to capture the press on. Accepts an element ref, getter,
|
||||
* or component instance. Defaults to `window` when omitted.
|
||||
*/
|
||||
target?: MaybeComputedElementRef;
|
||||
|
||||
/**
|
||||
* Callback invoked when a press starts
|
||||
*/
|
||||
onPressed?: (event: UseMousePressedEvent) => void;
|
||||
|
||||
/**
|
||||
* Callback invoked when a press is released
|
||||
*/
|
||||
onReleased?: (event: UseMousePressedEvent) => void;
|
||||
}
|
||||
|
||||
export interface UseMousePressedReturn {
|
||||
pressed: ShallowRef<boolean>;
|
||||
sourceType: ShallowRef<UseMouseSourceType>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useMousePressed
|
||||
* @category Browser
|
||||
* @description Reactive mouse/touch/drag pressed state on a target, with the
|
||||
* input source type and optional press/release callbacks.
|
||||
*
|
||||
* @param {UseMousePressedOptions} [options={}] Options
|
||||
* @returns {UseMousePressedReturn} Reactive `pressed` and `sourceType`
|
||||
*
|
||||
* @example
|
||||
* const { pressed, sourceType } = useMousePressed();
|
||||
*
|
||||
* @example
|
||||
* // Track presses only on a specific element, ignore touch
|
||||
* const { pressed } = useMousePressed({ target: el, touch: false });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useMousePressed(options: UseMousePressedOptions = {}): UseMousePressedReturn {
|
||||
const {
|
||||
touch = true,
|
||||
drag = true,
|
||||
capture = false,
|
||||
initialValue = false,
|
||||
window = defaultWindow,
|
||||
} = options;
|
||||
|
||||
const pressed = shallowRef(initialValue);
|
||||
const sourceType = shallowRef<UseMouseSourceType>(null);
|
||||
|
||||
if (!window)
|
||||
return { pressed, sourceType };
|
||||
|
||||
const onPressed = (srcType: UseMouseSourceType) => (event: UseMousePressedEvent): void => {
|
||||
pressed.value = true;
|
||||
sourceType.value = srcType;
|
||||
options.onPressed?.(event);
|
||||
};
|
||||
|
||||
const onReleased = (event: UseMousePressedEvent): void => {
|
||||
pressed.value = false;
|
||||
sourceType.value = null;
|
||||
options.onReleased?.(event);
|
||||
};
|
||||
|
||||
const target: ComputedRef<EventTarget> = computed(() => unrefElement(options.target) ?? window);
|
||||
|
||||
const listenerOptions = { passive: true, capture };
|
||||
|
||||
useEventListener(target, 'mousedown', onPressed('mouse') as (e: Event) => void, listenerOptions);
|
||||
useEventListener(window, 'mouseleave', onReleased as (e: Event) => void, listenerOptions);
|
||||
useEventListener(window, 'mouseup', onReleased as (e: Event) => void, listenerOptions);
|
||||
|
||||
if (drag) {
|
||||
useEventListener(target, 'dragstart', onPressed('mouse') as (e: Event) => void, listenerOptions);
|
||||
useEventListener(window, 'drop', onReleased as (e: Event) => void, listenerOptions);
|
||||
useEventListener(window, 'dragend', onReleased as (e: Event) => void, listenerOptions);
|
||||
}
|
||||
|
||||
if (touch) {
|
||||
useEventListener(target, 'touchstart', onPressed('touch') as (e: Event) => void, listenerOptions);
|
||||
useEventListener(window, 'touchend', onReleased as (e: Event) => void, listenerOptions);
|
||||
useEventListener(window, 'touchcancel', onReleased as (e: Event) => void, listenerOptions);
|
||||
}
|
||||
|
||||
return { pressed, sourceType };
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick, ref } from 'vue';
|
||||
import { useMutationObserver } from '.';
|
||||
|
||||
let instances: Array<{ cb: MutationCallback; observe: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn>; takeRecords: ReturnType<typeof vi.fn> }> = [];
|
||||
|
||||
class StubMutationObserver {
|
||||
observe = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
takeRecords = vi.fn(() => []);
|
||||
cb: MutationCallback;
|
||||
constructor(cb: MutationCallback) {
|
||||
this.cb = cb;
|
||||
instances.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
describe(useMutationObserver, () => {
|
||||
beforeEach(() => {
|
||||
instances = [];
|
||||
vi.stubGlobal('MutationObserver', StubMutationObserver);
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('observes the target with the given options', () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
scope.run(() => useMutationObserver(ref(el), vi.fn(), { attributes: true }));
|
||||
|
||||
expect(instances).toHaveLength(1);
|
||||
expect(instances[0]!.observe).toHaveBeenCalledWith(el, { attributes: true });
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not leak immediate/window into observer options', () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
scope.run(() => useMutationObserver(ref(el), vi.fn(), { childList: true, immediate: true }));
|
||||
|
||||
expect(instances[0]!.observe).toHaveBeenCalledWith(el, { childList: true });
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('disconnects on stop', () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let stop: () => void;
|
||||
scope.run(() => {
|
||||
stop = useMutationObserver(ref(el), vi.fn()).stop;
|
||||
});
|
||||
|
||||
stop!();
|
||||
expect(instances[0]!.disconnect).toHaveBeenCalled();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('forwards records to the callback', () => {
|
||||
const el = document.createElement('div');
|
||||
const callback = vi.fn();
|
||||
const scope = effectScope();
|
||||
scope.run(() => useMutationObserver(ref(el), callback));
|
||||
|
||||
const records = [{ type: 'attributes' } as MutationRecord];
|
||||
instances[0]!.cb(records, instances[0] as unknown as MutationObserver);
|
||||
expect(callback).toHaveBeenCalledWith(records, expect.anything());
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('observes an array of targets with a single observer', () => {
|
||||
const a = document.createElement('div');
|
||||
const b = document.createElement('span');
|
||||
const scope = effectScope();
|
||||
scope.run(() => useMutationObserver([ref(a), b], vi.fn(), { childList: true }));
|
||||
|
||||
expect(instances).toHaveLength(1);
|
||||
expect(instances[0]!.observe).toHaveBeenCalledTimes(2);
|
||||
expect(instances[0]!.observe).toHaveBeenCalledWith(a, { childList: true });
|
||||
expect(instances[0]!.observe).toHaveBeenCalledWith(b, { childList: true });
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('accepts a getter returning an array of targets', () => {
|
||||
const a = document.createElement('div');
|
||||
const b = document.createElement('span');
|
||||
const scope = effectScope();
|
||||
scope.run(() => useMutationObserver(() => [a, b], vi.fn(), { childList: true }));
|
||||
|
||||
expect(instances).toHaveLength(1);
|
||||
expect(instances[0]!.observe).toHaveBeenCalledTimes(2);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('deduplicates repeated targets', () => {
|
||||
const a = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
scope.run(() => useMutationObserver([a, a, ref(a)], vi.fn(), { childList: true }));
|
||||
|
||||
expect(instances[0]!.observe).toHaveBeenCalledTimes(1);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('skips nullish targets', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => useMutationObserver([ref(null), ref(undefined)], vi.fn(), { childList: true }));
|
||||
|
||||
expect(instances).toHaveLength(0);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('re-observes when a reactive target changes', async () => {
|
||||
const el = document.createElement('div');
|
||||
const target = ref<HTMLElement | null>(null);
|
||||
const scope = effectScope();
|
||||
scope.run(() => useMutationObserver(target, vi.fn(), { childList: true }));
|
||||
|
||||
await nextTick();
|
||||
expect(instances).toHaveLength(0);
|
||||
|
||||
target.value = el;
|
||||
await nextTick();
|
||||
expect(instances).toHaveLength(1);
|
||||
expect(instances[0]!.observe).toHaveBeenCalledWith(el, { childList: true });
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not observe when immediate is false, then resumes', async () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let api: ReturnType<typeof useMutationObserver>;
|
||||
scope.run(() => {
|
||||
api = useMutationObserver(ref(el), vi.fn(), { attributes: true, immediate: false });
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
expect(instances).toHaveLength(0);
|
||||
expect(api!.isActive.value).toBeFalsy();
|
||||
|
||||
api!.resume();
|
||||
await nextTick();
|
||||
expect(instances).toHaveLength(1);
|
||||
expect(api!.isActive.value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('pause disconnects and flips isActive, resume re-observes', async () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let api: ReturnType<typeof useMutationObserver>;
|
||||
scope.run(() => {
|
||||
api = useMutationObserver(ref(el), vi.fn(), { attributes: true });
|
||||
});
|
||||
|
||||
expect(instances).toHaveLength(1);
|
||||
|
||||
api!.pause();
|
||||
expect(instances[0]!.disconnect).toHaveBeenCalled();
|
||||
expect(api!.isActive.value).toBeFalsy();
|
||||
|
||||
api!.resume();
|
||||
await nextTick();
|
||||
expect(instances).toHaveLength(2);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('takeRecords proxies to the active observer and returns undefined when inactive', () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let api: ReturnType<typeof useMutationObserver>;
|
||||
scope.run(() => {
|
||||
api = useMutationObserver(ref(el), vi.fn());
|
||||
});
|
||||
|
||||
expect(api!.takeRecords()).toEqual([]);
|
||||
expect(instances[0]!.takeRecords).toHaveBeenCalled();
|
||||
|
||||
api!.stop();
|
||||
expect(api!.takeRecords()).toBeUndefined();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reports isSupported false when MutationObserver is missing', () => {
|
||||
const scope = effectScope();
|
||||
let api: ReturnType<typeof useMutationObserver>;
|
||||
const el = document.createElement('div');
|
||||
scope.run(() => {
|
||||
api = useMutationObserver(ref(el), vi.fn(), { window: { foo: 1 } as unknown as Window & typeof globalThis });
|
||||
});
|
||||
|
||||
expect(api!.isSupported.value).toBeFalsy();
|
||||
expect(instances).toHaveLength(0);
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -1,140 +0,0 @@
|
||||
import { computed, readonly, ref, toValue, watch } from 'vue';
|
||||
import type { MaybeRefOrGetter, Ref } from 'vue';
|
||||
import { toArray } from '@robonen/stdlib';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { MaybeComputedElementRef, MaybeElement } from '@/composables/component/unrefElement';
|
||||
import { unrefElement } from '@/composables/component/unrefElement';
|
||||
import { useSupported } from '@/composables/browser/useSupported';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
|
||||
export interface UseMutationObserverOptions extends MutationObserverInit, ConfigurableWindow {
|
||||
/**
|
||||
* Start observing immediately once a target is available
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
immediate?: boolean;
|
||||
}
|
||||
|
||||
export interface UseMutationObserverReturn {
|
||||
isSupported: Readonly<Ref<boolean>>;
|
||||
/**
|
||||
* Whether the observer is currently active (not paused or stopped)
|
||||
*/
|
||||
isActive: Readonly<Ref<boolean>>;
|
||||
/**
|
||||
* Temporarily disconnect the observer without tearing down the watcher.
|
||||
* Re-observe with `resume`.
|
||||
*/
|
||||
pause: () => void;
|
||||
/**
|
||||
* Re-attach the observer to the current target(s) after a `pause`.
|
||||
*/
|
||||
resume: () => void;
|
||||
/**
|
||||
* Permanently stop observing and dispose the watcher.
|
||||
*/
|
||||
stop: () => void;
|
||||
/**
|
||||
* Synchronously take and clear the observer's record queue
|
||||
*/
|
||||
takeRecords: () => MutationRecord[] | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useMutationObserver
|
||||
* @category Browser
|
||||
* @description Watch for changes to the DOM tree via `MutationObserver`.
|
||||
* Accepts a single target, an array of targets, or a getter returning either.
|
||||
*
|
||||
* @param {MaybeComputedElementRef | MaybeComputedElementRef[] | MaybeRefOrGetter<MaybeElement[]>} target Element(s) to observe
|
||||
* @param {MutationCallback} callback Invoked with the mutation records
|
||||
* @param {UseMutationObserverOptions} [options={}] Observer options (childList, attributes, …)
|
||||
* @returns {UseMutationObserverReturn} `isSupported`, `isActive`, `pause`, `resume`, `stop`, and `takeRecords`
|
||||
*
|
||||
* @example
|
||||
* useMutationObserver(el, (records) => {
|
||||
* console.log(records);
|
||||
* }, { attributes: true });
|
||||
*
|
||||
* @example
|
||||
* const { pause, resume } = useMutationObserver([elA, elB], onMutate, { childList: true });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useMutationObserver(
|
||||
target: MaybeComputedElementRef | MaybeComputedElementRef[] | MaybeRefOrGetter<MaybeElement[]>,
|
||||
callback: MutationCallback,
|
||||
options: UseMutationObserverOptions = {},
|
||||
): UseMutationObserverReturn {
|
||||
const { window = defaultWindow, immediate = true, ...observerOptions } = options;
|
||||
|
||||
const isSupported = useSupported(() => window && 'MutationObserver' in window);
|
||||
|
||||
let observer: MutationObserver | undefined;
|
||||
|
||||
const isActive = ref(immediate);
|
||||
|
||||
const targets = computed(() => {
|
||||
const value = toArray(toValue(target));
|
||||
const set = new Set<Element>();
|
||||
|
||||
for (const item of value) {
|
||||
const el = unrefElement(item as MaybeComputedElementRef);
|
||||
if (el)
|
||||
set.add(el);
|
||||
}
|
||||
|
||||
return set;
|
||||
});
|
||||
|
||||
const cleanup = () => {
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
observer = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const takeRecords = () => observer?.takeRecords();
|
||||
|
||||
const stopWatch = watch(
|
||||
() => [targets.value, isActive.value] as const,
|
||||
([els, active]) => {
|
||||
cleanup();
|
||||
|
||||
if (!active || !isSupported.value || !window || !els.size)
|
||||
return;
|
||||
|
||||
observer = new MutationObserver(callback);
|
||||
for (const el of els)
|
||||
observer.observe(el, observerOptions);
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
);
|
||||
|
||||
const resume = () => {
|
||||
isActive.value = true;
|
||||
};
|
||||
|
||||
const pause = () => {
|
||||
cleanup();
|
||||
isActive.value = false;
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
cleanup();
|
||||
stopWatch();
|
||||
};
|
||||
|
||||
tryOnScopeDispose(stop);
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
isActive: readonly(isActive),
|
||||
pause,
|
||||
resume,
|
||||
stop,
|
||||
takeRecords,
|
||||
};
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick } from 'vue';
|
||||
import { useNetwork } from '.';
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe(useNetwork, () => {
|
||||
it('exposes the full reactive network state shape', () => {
|
||||
const scope = effectScope();
|
||||
let state: ReturnType<typeof useNetwork>;
|
||||
scope.run(() => {
|
||||
state = useNetwork();
|
||||
});
|
||||
|
||||
expect(state!.isOnline.value).toBe(navigator.onLine);
|
||||
expect(state!.type.value).toBe('unknown');
|
||||
// while online on mount, offlineAt stays undefined and onlineAt is stamped
|
||||
expect(state!.offlineAt.value).toBeUndefined();
|
||||
expect(typeof state!.onlineAt.value).toBe('number');
|
||||
expect(state!.downlink.value).toBeUndefined();
|
||||
expect(state!.downlinkMax.value).toBeUndefined();
|
||||
expect(state!.rtt.value).toBeUndefined();
|
||||
expect(state!.effectiveType.value).toBeUndefined();
|
||||
expect(state!.saveData.value).toBeUndefined();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reflects the initial navigator.onLine from a supplied window', () => {
|
||||
const fakeWindow = {
|
||||
navigator: { onLine: false },
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
} as unknown as Window;
|
||||
|
||||
const scope = effectScope();
|
||||
let state: ReturnType<typeof useNetwork>;
|
||||
scope.run(() => {
|
||||
state = useNetwork({ window: fakeWindow });
|
||||
});
|
||||
expect(state!.isOnline.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('records offlineAt and onlineAt timestamps on transitions', async () => {
|
||||
const scope = effectScope();
|
||||
let state: ReturnType<typeof useNetwork>;
|
||||
scope.run(() => {
|
||||
state = useNetwork();
|
||||
});
|
||||
|
||||
expect(state!.offlineAt.value).toBeUndefined();
|
||||
|
||||
globalThis.dispatchEvent(new Event('offline'));
|
||||
await nextTick();
|
||||
expect(state!.isOnline.value).toBeFalsy();
|
||||
expect(typeof state!.offlineAt.value).toBe('number');
|
||||
|
||||
globalThis.dispatchEvent(new Event('online'));
|
||||
await nextTick();
|
||||
expect(state!.isOnline.value).toBeTruthy();
|
||||
expect(typeof state!.onlineAt.value).toBe('number');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('stays inert and reports online when window is undefined (SSR)', () => {
|
||||
const scope = effectScope();
|
||||
let state: ReturnType<typeof useNetwork>;
|
||||
scope.run(() => {
|
||||
state = useNetwork({ window: undefined });
|
||||
});
|
||||
|
||||
expect(state!.isOnline.value).toBeTruthy();
|
||||
expect(state!.isSupported.value).toBeFalsy();
|
||||
expect(state!.type.value).toBe('unknown');
|
||||
expect(state!.downlink.value).toBeUndefined();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('isSupported is false when Network Information API is unavailable', () => {
|
||||
const fakeWindow = {
|
||||
navigator: { onLine: true },
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
} as unknown as Window;
|
||||
|
||||
const scope = effectScope();
|
||||
let state: ReturnType<typeof useNetwork>;
|
||||
scope.run(() => {
|
||||
state = useNetwork({ window: fakeWindow });
|
||||
});
|
||||
expect(state!.isSupported.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reads connection info and listens for change events when supported', () => {
|
||||
const listeners: Record<string, Array<(e: Event) => void>> = {};
|
||||
const connection = {
|
||||
downlink: 10,
|
||||
downlinkMax: 100,
|
||||
effectiveType: '4g',
|
||||
rtt: 50,
|
||||
saveData: true,
|
||||
type: 'wifi',
|
||||
addEventListener: (event: string, cb: (e: Event) => void) => {
|
||||
(listeners[event] ??= []).push(cb);
|
||||
},
|
||||
removeEventListener: () => {},
|
||||
};
|
||||
|
||||
const fakeWindow = {
|
||||
navigator: { onLine: true, connection },
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
} as unknown as Window;
|
||||
|
||||
const scope = effectScope();
|
||||
let state: ReturnType<typeof useNetwork>;
|
||||
scope.run(() => {
|
||||
state = useNetwork({ window: fakeWindow });
|
||||
});
|
||||
|
||||
expect(state!.isSupported.value).toBeTruthy();
|
||||
expect(state!.downlink.value).toBe(10);
|
||||
expect(state!.downlinkMax.value).toBe(100);
|
||||
expect(state!.effectiveType.value).toBe('4g');
|
||||
expect(state!.rtt.value).toBe(50);
|
||||
expect(state!.saveData.value).toBeTruthy();
|
||||
expect(state!.type.value).toBe('wifi');
|
||||
|
||||
// a registered change listener should re-read connection state
|
||||
connection.downlink = 5;
|
||||
connection.effectiveType = '3g';
|
||||
listeners.change?.forEach(cb => cb(new Event('change')));
|
||||
expect(state!.downlink.value).toBe(5);
|
||||
expect(state!.effectiveType.value).toBe('3g');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('falls back to "unknown" type when connection.type is missing', () => {
|
||||
const connection = {
|
||||
downlink: 8,
|
||||
effectiveType: '4g',
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
};
|
||||
|
||||
const fakeWindow = {
|
||||
navigator: { onLine: true, connection },
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
} as unknown as Window;
|
||||
|
||||
const scope = effectScope();
|
||||
let state: ReturnType<typeof useNetwork>;
|
||||
scope.run(() => {
|
||||
state = useNetwork({ window: fakeWindow });
|
||||
});
|
||||
|
||||
expect(state!.isSupported.value).toBeTruthy();
|
||||
expect(state!.type.value).toBe('unknown');
|
||||
expect(state!.downlink.value).toBe(8);
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -1,157 +0,0 @@
|
||||
import { shallowReadonly, shallowRef } from 'vue';
|
||||
import type { ShallowRef } from 'vue';
|
||||
import { timestamp } from '@robonen/stdlib';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
import { useSupported } from '@/composables/browser/useSupported';
|
||||
|
||||
export type NetworkType
|
||||
= | 'bluetooth'
|
||||
| 'cellular'
|
||||
| 'ethernet'
|
||||
| 'none'
|
||||
| 'wifi'
|
||||
| 'wimax'
|
||||
| 'other'
|
||||
| 'unknown';
|
||||
|
||||
export type NetworkEffectiveType = 'slow-2g' | '2g' | '3g' | '4g' | undefined;
|
||||
|
||||
export interface UseNetworkOptions extends ConfigurableWindow {}
|
||||
|
||||
export interface UseNetworkReturn {
|
||||
/**
|
||||
* Whether the Network Information API (`navigator.connection`) is supported.
|
||||
*/
|
||||
isSupported: Readonly<ShallowRef<boolean>>;
|
||||
/**
|
||||
* Whether the browser is currently online (`navigator.onLine`).
|
||||
*/
|
||||
isOnline: Readonly<ShallowRef<boolean>>;
|
||||
/**
|
||||
* The timestamp of the last time the browser went offline, in ms.
|
||||
*/
|
||||
offlineAt: Readonly<ShallowRef<number | undefined>>;
|
||||
/**
|
||||
* The timestamp of the last time the browser came back online, in ms.
|
||||
*/
|
||||
onlineAt: Readonly<ShallowRef<number | undefined>>;
|
||||
/**
|
||||
* The estimated effective bandwidth in megabits per second.
|
||||
*/
|
||||
downlink: Readonly<ShallowRef<number | undefined>>;
|
||||
/**
|
||||
* The maximum downlink speed of the underlying connection technology, in Mbps.
|
||||
*/
|
||||
downlinkMax: Readonly<ShallowRef<number | undefined>>;
|
||||
/**
|
||||
* The effective type of the connection (`slow-2g`, `2g`, `3g`, or `4g`).
|
||||
*/
|
||||
effectiveType: Readonly<ShallowRef<NetworkEffectiveType>>;
|
||||
/**
|
||||
* The estimated effective round-trip time of the current connection, in ms.
|
||||
*/
|
||||
rtt: Readonly<ShallowRef<number | undefined>>;
|
||||
/**
|
||||
* Whether the user has requested a reduced data usage mode.
|
||||
*/
|
||||
saveData: Readonly<ShallowRef<boolean | undefined>>;
|
||||
/**
|
||||
* The type of connection a device is using to communicate with the network.
|
||||
*/
|
||||
type: Readonly<ShallowRef<NetworkType>>;
|
||||
}
|
||||
|
||||
interface NetworkInformation extends EventTarget {
|
||||
readonly downlink?: number;
|
||||
readonly downlinkMax?: number;
|
||||
readonly effectiveType?: NetworkEffectiveType;
|
||||
readonly rtt?: number;
|
||||
readonly saveData?: boolean;
|
||||
readonly type?: NetworkType;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useNetwork
|
||||
* @category Browser
|
||||
* @description Reactive Network Information API state plus online/offline status.
|
||||
*
|
||||
* @param {UseNetworkOptions} [options={}] Options
|
||||
* @returns {UseNetworkReturn} Reactive online status, transition timestamps, and connection info
|
||||
*
|
||||
* @example
|
||||
* const { isOnline, offlineAt, downlink, effectiveType, saveData, type } = useNetwork();
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useNetwork(options: UseNetworkOptions = {}): UseNetworkReturn {
|
||||
const { window = defaultWindow } = options;
|
||||
const navigator = window?.navigator;
|
||||
|
||||
const isSupported = useSupported(() => !!navigator && 'connection' in navigator);
|
||||
|
||||
const isOnline = shallowRef(navigator?.onLine ?? true);
|
||||
const saveData = shallowRef<boolean | undefined>(undefined);
|
||||
const offlineAt = shallowRef<number | undefined>(undefined);
|
||||
const onlineAt = shallowRef<number | undefined>(undefined);
|
||||
const downlink = shallowRef<number | undefined>(undefined);
|
||||
const downlinkMax = shallowRef<number | undefined>(undefined);
|
||||
const rtt = shallowRef<number | undefined>(undefined);
|
||||
const effectiveType = shallowRef<NetworkEffectiveType>(undefined);
|
||||
const type = shallowRef<NetworkType>('unknown');
|
||||
|
||||
const connection = navigator && 'connection' in navigator
|
||||
? (navigator as Navigator & { connection?: NetworkInformation }).connection
|
||||
: undefined;
|
||||
|
||||
function updateNetworkInformation(): void {
|
||||
if (!navigator)
|
||||
return;
|
||||
|
||||
isOnline.value = navigator.onLine;
|
||||
offlineAt.value = isOnline.value ? offlineAt.value : timestamp();
|
||||
onlineAt.value = isOnline.value ? timestamp() : onlineAt.value;
|
||||
|
||||
if (connection) {
|
||||
downlink.value = connection.downlink;
|
||||
downlinkMax.value = connection.downlinkMax;
|
||||
effectiveType.value = connection.effectiveType;
|
||||
rtt.value = connection.rtt;
|
||||
saveData.value = connection.saveData;
|
||||
type.value = connection.type ?? 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
const listenerOptions = { passive: true } as const;
|
||||
|
||||
if (window) {
|
||||
useEventListener(window, 'offline', () => {
|
||||
isOnline.value = false;
|
||||
offlineAt.value = timestamp();
|
||||
}, listenerOptions);
|
||||
|
||||
useEventListener(window, 'online', () => {
|
||||
isOnline.value = true;
|
||||
onlineAt.value = timestamp();
|
||||
}, listenerOptions);
|
||||
}
|
||||
|
||||
if (connection)
|
||||
useEventListener(connection, 'change', updateNetworkInformation, listenerOptions);
|
||||
|
||||
updateNetworkInformation();
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
isOnline: shallowReadonly(isOnline),
|
||||
saveData: shallowReadonly(saveData),
|
||||
offlineAt: shallowReadonly(offlineAt),
|
||||
onlineAt: shallowReadonly(onlineAt),
|
||||
downlink: shallowReadonly(downlink),
|
||||
downlinkMax: shallowReadonly(downlinkMax),
|
||||
effectiveType: shallowReadonly(effectiveType),
|
||||
rtt: shallowReadonly(rtt),
|
||||
type: shallowReadonly(type),
|
||||
};
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick } from 'vue';
|
||||
import { useOnline } from '.';
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe(useOnline, () => {
|
||||
it('reflects the initial navigator.onLine', () => {
|
||||
const scope = effectScope();
|
||||
let online: ReturnType<typeof useOnline>;
|
||||
scope.run(() => {
|
||||
online = useOnline();
|
||||
});
|
||||
expect(online!.value).toBe(navigator.onLine);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('updates on offline/online events', async () => {
|
||||
const scope = effectScope();
|
||||
let online: ReturnType<typeof useOnline>;
|
||||
scope.run(() => {
|
||||
online = useOnline();
|
||||
});
|
||||
|
||||
globalThis.dispatchEvent(new Event('offline'));
|
||||
await nextTick();
|
||||
expect(online!.value).toBeFalsy();
|
||||
|
||||
globalThis.dispatchEvent(new Event('online'));
|
||||
await nextTick();
|
||||
expect(online!.value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('defaults to true when there is no window (SSR)', () => {
|
||||
const scope = effectScope();
|
||||
let online: ReturnType<typeof useOnline>;
|
||||
scope.run(() => {
|
||||
online = useOnline({ window: undefined as any });
|
||||
});
|
||||
expect(online!.value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
import { shallowReadonly, shallowRef } from 'vue';
|
||||
import type { ShallowRef } from 'vue';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
|
||||
export interface UseOnlineOptions extends ConfigurableWindow {}
|
||||
|
||||
/**
|
||||
* @name useOnline
|
||||
* @category Browser
|
||||
* @description Reactive online/offline status based on `navigator.onLine`.
|
||||
* For connection details (effectiveType, downlink, saveData, transition
|
||||
* timestamps, ...) use {@link useNetwork} instead.
|
||||
*
|
||||
* @param {UseOnlineOptions} [options={}] Options
|
||||
* @returns {Readonly<ShallowRef<boolean>>} Whether the browser is online
|
||||
*
|
||||
* @example
|
||||
* const online = useOnline();
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useOnline(options: UseOnlineOptions = {}): Readonly<ShallowRef<boolean>> {
|
||||
const { window = defaultWindow } = options;
|
||||
|
||||
const isOnline = shallowRef(window?.navigator?.onLine ?? true);
|
||||
|
||||
useEventListener(window, 'online', () => {
|
||||
isOnline.value = true;
|
||||
}, { passive: true });
|
||||
|
||||
useEventListener(window, 'offline', () => {
|
||||
isOnline.value = false;
|
||||
}, { passive: true });
|
||||
|
||||
return shallowReadonly(isOnline);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, isReadonly, nextTick } from 'vue';
|
||||
import { usePageLeave } from '.';
|
||||
|
||||
function withScope<T>(fn: () => T): { value: T; stop: () => void } {
|
||||
const scope = effectScope();
|
||||
let value!: T;
|
||||
scope.run(() => {
|
||||
value = fn();
|
||||
});
|
||||
return { value, stop: () => scope.stop() };
|
||||
}
|
||||
|
||||
describe(usePageLeave, () => {
|
||||
it('is false initially', () => {
|
||||
const { value: isLeft, stop } = withScope(() => usePageLeave());
|
||||
expect(isLeft.value).toBeFalsy();
|
||||
stop();
|
||||
});
|
||||
|
||||
it('returns a writable shallow ref', () => {
|
||||
const { value: isLeft, stop } = withScope(() => usePageLeave());
|
||||
expect(isReadonly(isLeft)).toBeFalsy();
|
||||
stop();
|
||||
});
|
||||
|
||||
it('becomes true when the pointer leaves the document', async () => {
|
||||
const { value: isLeft, stop } = withScope(() => usePageLeave());
|
||||
|
||||
document.documentElement.dispatchEvent(new MouseEvent('mouseleave'));
|
||||
await nextTick();
|
||||
expect(isLeft.value).toBeTruthy();
|
||||
|
||||
document.documentElement.dispatchEvent(new MouseEvent('mouseenter'));
|
||||
await nextTick();
|
||||
expect(isLeft.value).toBeFalsy();
|
||||
stop();
|
||||
});
|
||||
|
||||
it('uses mouseout relatedTarget to detect leaving', async () => {
|
||||
const { value: isLeft, stop } = withScope(() => usePageLeave());
|
||||
|
||||
// No relatedTarget => pointer left the page.
|
||||
globalThis.dispatchEvent(new MouseEvent('mouseout'));
|
||||
await nextTick();
|
||||
expect(isLeft.value).toBeTruthy();
|
||||
|
||||
// relatedTarget present => pointer moved to another element, still on page.
|
||||
globalThis.dispatchEvent(new MouseEvent('mouseout', { relatedTarget: document.body }));
|
||||
await nextTick();
|
||||
expect(isLeft.value).toBeFalsy();
|
||||
stop();
|
||||
});
|
||||
|
||||
it('invokes onChange with the new value and event only on change', async () => {
|
||||
const onChange = vi.fn();
|
||||
const { stop } = withScope(() => usePageLeave({ onChange }));
|
||||
|
||||
document.documentElement.dispatchEvent(new MouseEvent('mouseleave'));
|
||||
await nextTick();
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenLastCalledWith(true, expect.any(MouseEvent));
|
||||
|
||||
// Repeated leave should not fire again (no state change).
|
||||
document.documentElement.dispatchEvent(new MouseEvent('mouseleave'));
|
||||
await nextTick();
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
|
||||
document.documentElement.dispatchEvent(new MouseEvent('mouseenter'));
|
||||
await nextTick();
|
||||
expect(onChange).toHaveBeenCalledTimes(2);
|
||||
expect(onChange).toHaveBeenLastCalledWith(false, expect.any(MouseEvent));
|
||||
stop();
|
||||
});
|
||||
|
||||
it('does not throw and stays false when window is undefined (SSR)', () => {
|
||||
const { value: isLeft, stop } = withScope(() =>
|
||||
usePageLeave({ window: undefined }),
|
||||
);
|
||||
expect(isLeft.value).toBeFalsy();
|
||||
stop();
|
||||
});
|
||||
|
||||
it('binds listeners to a custom window', async () => {
|
||||
const onChange = vi.fn();
|
||||
const { stop } = withScope(() => usePageLeave({ window: globalThis as unknown as Window, onChange }));
|
||||
|
||||
document.documentElement.dispatchEvent(new MouseEvent('mouseleave'));
|
||||
await nextTick();
|
||||
expect(onChange).toHaveBeenCalledWith(true, expect.any(MouseEvent));
|
||||
document.documentElement.dispatchEvent(new MouseEvent('mouseenter'));
|
||||
await nextTick();
|
||||
stop();
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import { shallowRef } from 'vue';
|
||||
import type { ShallowRef } from 'vue';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
|
||||
export interface UsePageLeaveOptions extends ConfigurableWindow {
|
||||
/**
|
||||
* Called whenever the leave state flips, receiving the new value and the
|
||||
* originating mouse event.
|
||||
*
|
||||
* @default undefined
|
||||
*/
|
||||
onChange?: (isLeft: boolean, event: MouseEvent) => void;
|
||||
}
|
||||
|
||||
export type UsePageLeaveReturn = ShallowRef<boolean>;
|
||||
|
||||
/**
|
||||
* @name usePageLeave
|
||||
* @category Browser
|
||||
* @description Reactive flag indicating whether the mouse has left the page.
|
||||
*
|
||||
* @param {UsePageLeaveOptions} [options={}] Options (custom `window`, `onChange` callback)
|
||||
* @returns {UsePageLeaveReturn} Whether the pointer has left the page
|
||||
*
|
||||
* @example
|
||||
* const hasLeft = usePageLeave();
|
||||
*
|
||||
* @example
|
||||
* usePageLeave({
|
||||
* onChange: (isLeft) => {
|
||||
* if (isLeft) showExitIntentModal();
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function usePageLeave(options: UsePageLeaveOptions = {}): UsePageLeaveReturn {
|
||||
const { window = defaultWindow, onChange } = options;
|
||||
|
||||
const isLeft = shallowRef(false);
|
||||
|
||||
const update = (left: boolean, event: MouseEvent) => {
|
||||
if (left === isLeft.value)
|
||||
return;
|
||||
|
||||
isLeft.value = left;
|
||||
onChange?.(left, event);
|
||||
};
|
||||
|
||||
if (window) {
|
||||
const documentElement = window.document.documentElement;
|
||||
const listenerOptions = { passive: true } as const;
|
||||
|
||||
useEventListener(window, 'mouseout', (event) => {
|
||||
const from = event.relatedTarget || (event as MouseEvent & { toElement?: EventTarget }).toElement;
|
||||
update(!from, event);
|
||||
}, listenerOptions);
|
||||
|
||||
useEventListener(documentElement, 'mouseleave', (event) => {
|
||||
update(true, event);
|
||||
}, listenerOptions);
|
||||
|
||||
useEventListener(documentElement, 'mouseenter', (event) => {
|
||||
update(false, event);
|
||||
}, listenerOptions);
|
||||
}
|
||||
|
||||
return isLeft;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { Ref, ShallowRef } from 'vue';
|
||||
import { isString } from '@robonen/stdlib';
|
||||
import { defaultNavigator } from '@/types';
|
||||
import type { ConfigurableNavigator } from '@/types';
|
||||
import { useSupported } from '@/composables/browser/useSupported';
|
||||
import { useSupported } from '@/composables/utilities/useSupported';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick, shallowRef } from 'vue';
|
||||
import { usePointer } from '.';
|
||||
|
||||
interface PointerProps {
|
||||
x?: number;
|
||||
y?: number;
|
||||
pressure?: number;
|
||||
pointerId?: number;
|
||||
tiltX?: number;
|
||||
tiltY?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
twist?: number;
|
||||
pointerType?: string;
|
||||
}
|
||||
|
||||
function dispatchPointer(target: EventTarget, type: string, props: PointerProps = {}) {
|
||||
const event = new Event(type, { bubbles: true });
|
||||
for (const [key, value] of Object.entries(props))
|
||||
Object.defineProperty(event, key, { value, configurable: true });
|
||||
target.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
describe(usePointer, () => {
|
||||
it('starts from the default state', () => {
|
||||
const scope = effectScope();
|
||||
let ptr: ReturnType<typeof usePointer>;
|
||||
scope.run(() => {
|
||||
ptr = usePointer();
|
||||
});
|
||||
|
||||
expect(ptr!.x.value).toBe(0);
|
||||
expect(ptr!.y.value).toBe(0);
|
||||
expect(ptr!.pressure.value).toBe(0);
|
||||
expect(ptr!.pointerId.value).toBe(0);
|
||||
expect(ptr!.tiltX.value).toBe(0);
|
||||
expect(ptr!.tiltY.value).toBe(0);
|
||||
expect(ptr!.width.value).toBe(0);
|
||||
expect(ptr!.height.value).toBe(0);
|
||||
expect(ptr!.twist.value).toBe(0);
|
||||
expect(ptr!.pointerType.value).toBe(null);
|
||||
expect(ptr!.isInside.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('merges the initial value over defaults', () => {
|
||||
const scope = effectScope();
|
||||
let ptr: ReturnType<typeof usePointer>;
|
||||
scope.run(() => {
|
||||
ptr = usePointer({ initialValue: { x: 12, pressure: 0.5 } });
|
||||
});
|
||||
|
||||
expect(ptr!.x.value).toBe(12);
|
||||
expect(ptr!.pressure.value).toBe(0.5);
|
||||
expect(ptr!.y.value).toBe(0);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('tracks pointermove and populates the full state', async () => {
|
||||
const scope = effectScope();
|
||||
let ptr: ReturnType<typeof usePointer>;
|
||||
scope.run(() => {
|
||||
ptr = usePointer();
|
||||
});
|
||||
|
||||
dispatchPointer(globalThis, 'pointermove', {
|
||||
x: 100,
|
||||
y: 200,
|
||||
pressure: 0.7,
|
||||
pointerId: 3,
|
||||
tiltX: 10,
|
||||
tiltY: -5,
|
||||
width: 4,
|
||||
height: 6,
|
||||
twist: 90,
|
||||
pointerType: 'pen',
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(ptr!.x.value).toBe(100);
|
||||
expect(ptr!.y.value).toBe(200);
|
||||
expect(ptr!.pressure.value).toBe(0.7);
|
||||
expect(ptr!.pointerId.value).toBe(3);
|
||||
expect(ptr!.tiltX.value).toBe(10);
|
||||
expect(ptr!.tiltY.value).toBe(-5);
|
||||
expect(ptr!.width.value).toBe(4);
|
||||
expect(ptr!.height.value).toBe(6);
|
||||
expect(ptr!.twist.value).toBe(90);
|
||||
expect(ptr!.pointerType.value).toBe('pen');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('sets isInside true on pointer activity and false on pointerleave', async () => {
|
||||
const scope = effectScope();
|
||||
let ptr: ReturnType<typeof usePointer>;
|
||||
scope.run(() => {
|
||||
ptr = usePointer();
|
||||
});
|
||||
|
||||
expect(ptr!.isInside.value).toBeFalsy();
|
||||
|
||||
dispatchPointer(globalThis, 'pointerdown', { x: 1, y: 2, pointerType: 'mouse' });
|
||||
await nextTick();
|
||||
expect(ptr!.isInside.value).toBeTruthy();
|
||||
|
||||
dispatchPointer(globalThis, 'pointerleave');
|
||||
await nextTick();
|
||||
expect(ptr!.isInside.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('tracks pointerdown and pointerup', async () => {
|
||||
const scope = effectScope();
|
||||
let ptr: ReturnType<typeof usePointer>;
|
||||
scope.run(() => {
|
||||
ptr = usePointer();
|
||||
});
|
||||
|
||||
dispatchPointer(globalThis, 'pointerdown', { x: 5, y: 5, pointerType: 'mouse' });
|
||||
await nextTick();
|
||||
expect(ptr!.x.value).toBe(5);
|
||||
|
||||
dispatchPointer(globalThis, 'pointerup', { x: 9, y: 9, pointerType: 'mouse' });
|
||||
await nextTick();
|
||||
expect(ptr!.x.value).toBe(9);
|
||||
expect(ptr!.y.value).toBe(9);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('ignores events whose pointerType is not allowed', async () => {
|
||||
const scope = effectScope();
|
||||
let ptr: ReturnType<typeof usePointer>;
|
||||
scope.run(() => {
|
||||
ptr = usePointer({ pointerTypes: ['pen'], initialValue: { x: 1, y: 2 } });
|
||||
});
|
||||
|
||||
dispatchPointer(globalThis, 'pointermove', { x: 50, y: 60, pointerType: 'mouse' });
|
||||
await nextTick();
|
||||
// mouse rejected: position unchanged, but isInside still flips true
|
||||
expect(ptr!.x.value).toBe(1);
|
||||
expect(ptr!.y.value).toBe(2);
|
||||
expect(ptr!.isInside.value).toBeTruthy();
|
||||
|
||||
dispatchPointer(globalThis, 'pointermove', { x: 50, y: 60, pointerType: 'pen' });
|
||||
await nextTick();
|
||||
expect(ptr!.x.value).toBe(50);
|
||||
expect(ptr!.y.value).toBe(60);
|
||||
expect(ptr!.pointerType.value).toBe('pen');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('attaches passive listeners', () => {
|
||||
const addSpy = vi.spyOn(globalThis, 'addEventListener');
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
usePointer();
|
||||
});
|
||||
|
||||
const moveCall = addSpy.mock.calls.find(([name]) => name === 'pointermove');
|
||||
expect(moveCall).toBeDefined();
|
||||
expect((moveCall![2] as AddEventListenerOptions).passive).toBeTruthy();
|
||||
|
||||
addSpy.mockRestore();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('listens on a custom element target', async () => {
|
||||
const el = document.createElement('div');
|
||||
const elRef = shallowRef(el);
|
||||
const addSpy = vi.spyOn(el, 'addEventListener');
|
||||
|
||||
const scope = effectScope();
|
||||
let ptr: ReturnType<typeof usePointer>;
|
||||
scope.run(() => {
|
||||
ptr = usePointer({ target: elRef });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(addSpy.mock.calls.some(([name]) => name === 'pointermove')).toBeTruthy();
|
||||
|
||||
dispatchPointer(el, 'pointermove', { x: 7, y: 8, pointerType: 'mouse' });
|
||||
await nextTick();
|
||||
|
||||
expect(ptr!.x.value).toBe(7);
|
||||
expect(ptr!.y.value).toBe(8);
|
||||
|
||||
addSpy.mockRestore();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('exposes writable state refs', () => {
|
||||
const scope = effectScope();
|
||||
let ptr: ReturnType<typeof usePointer>;
|
||||
scope.run(() => {
|
||||
ptr = usePointer();
|
||||
});
|
||||
|
||||
ptr!.x.value = 42;
|
||||
expect(ptr!.x.value).toBe(42);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does nothing when window is unavailable (SSR)', () => {
|
||||
const scope = effectScope();
|
||||
let ptr: ReturnType<typeof usePointer>;
|
||||
scope.run(() => {
|
||||
ptr = usePointer({ window: undefined, target: undefined });
|
||||
});
|
||||
|
||||
expect(ptr!.x.value).toBe(0);
|
||||
expect(ptr!.y.value).toBe(0);
|
||||
expect(ptr!.pointerType.value).toBe(null);
|
||||
expect(ptr!.isInside.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -1,162 +0,0 @@
|
||||
import { computed, shallowRef } from 'vue';
|
||||
import type { ShallowRef, WritableComputedRef } from 'vue';
|
||||
import { pick } from '@robonen/stdlib';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
import { unrefElement } from '@/composables/component/unrefElement';
|
||||
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
||||
|
||||
export type UsePointerType = 'mouse' | 'touch' | 'pen';
|
||||
|
||||
export interface UsePointerState {
|
||||
x: number;
|
||||
y: number;
|
||||
pressure: number;
|
||||
pointerId: number;
|
||||
tiltX: number;
|
||||
tiltY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
twist: number;
|
||||
pointerType: UsePointerType | null;
|
||||
}
|
||||
|
||||
export interface UsePointerOptions extends ConfigurableWindow {
|
||||
/**
|
||||
* Pointer types that should be listened to.
|
||||
*
|
||||
* @default ['mouse', 'touch', 'pen']
|
||||
*/
|
||||
pointerTypes?: UsePointerType[];
|
||||
|
||||
/**
|
||||
* Initial pointer state.
|
||||
*/
|
||||
initialValue?: Partial<UsePointerState>;
|
||||
|
||||
/**
|
||||
* Target to attach the listeners to. Accepts a window, document, element ref,
|
||||
* getter, or component instance.
|
||||
*
|
||||
* @default window
|
||||
*/
|
||||
target?: MaybeComputedElementRef | Window | Document;
|
||||
}
|
||||
|
||||
export interface UsePointerReturn {
|
||||
x: WritableComputedRef<number>;
|
||||
y: WritableComputedRef<number>;
|
||||
pressure: WritableComputedRef<number>;
|
||||
pointerId: WritableComputedRef<number>;
|
||||
tiltX: WritableComputedRef<number>;
|
||||
tiltY: WritableComputedRef<number>;
|
||||
width: WritableComputedRef<number>;
|
||||
height: WritableComputedRef<number>;
|
||||
twist: WritableComputedRef<number>;
|
||||
pointerType: WritableComputedRef<UsePointerType | null>;
|
||||
isInside: ShallowRef<boolean>;
|
||||
}
|
||||
|
||||
const defaultState: UsePointerState = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
pointerId: 0,
|
||||
pressure: 0,
|
||||
tiltX: 0,
|
||||
tiltY: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
twist: 0,
|
||||
pointerType: null,
|
||||
};
|
||||
|
||||
const keys = Object.keys(defaultState) as Array<keyof UsePointerState>;
|
||||
|
||||
/**
|
||||
* @name usePointer
|
||||
* @category Browser
|
||||
* @description Reactive pointer state (position, pressure, tilt, size, and
|
||||
* pointer type) sourced from pointer events on a target, plus whether the
|
||||
* pointer is currently inside it.
|
||||
*
|
||||
* @param {UsePointerOptions} [options={}] Options
|
||||
* @returns {UsePointerReturn} Reactive pointer state refs and `isInside`
|
||||
*
|
||||
* @example
|
||||
* const { x, y, pressure, pointerType, isInside } = usePointer();
|
||||
*
|
||||
* @example
|
||||
* // Track a specific element, pen only
|
||||
* const { x, y, tiltX, tiltY } = usePointer({ target: el, pointerTypes: ['pen'] });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function usePointer(options: UsePointerOptions = {}): UsePointerReturn {
|
||||
const {
|
||||
pointerTypes,
|
||||
initialValue = {},
|
||||
window = defaultWindow,
|
||||
target = window,
|
||||
} = options;
|
||||
|
||||
const isInside = shallowRef(false);
|
||||
|
||||
const state = shallowRef<UsePointerState>({
|
||||
...defaultState,
|
||||
...initialValue,
|
||||
});
|
||||
|
||||
const handler = (event: PointerEvent) => {
|
||||
isInside.value = true;
|
||||
|
||||
if (pointerTypes && !pointerTypes.includes(event.pointerType as UsePointerType))
|
||||
return;
|
||||
|
||||
state.value = pick(event, keys) as UsePointerState;
|
||||
};
|
||||
|
||||
// A raw window/document/EventTarget is used directly (fast, non-reactive path
|
||||
// in useEventListener). Refs/getters/element instances are resolved lazily via
|
||||
// a getter so the listeners re-bind when the underlying element changes.
|
||||
const listenTarget = isTarget(target)
|
||||
? target
|
||||
: (): EventTarget | null | undefined => unrefElement(target as MaybeComputedElementRef) as EventTarget | null | undefined;
|
||||
|
||||
if (target) {
|
||||
const listenerOptions = { passive: true };
|
||||
|
||||
useEventListener(listenTarget, ['pointerdown', 'pointermove', 'pointerup'], handler as (e: Event) => void, listenerOptions);
|
||||
useEventListener(listenTarget, 'pointerleave', () => (isInside.value = false), listenerOptions);
|
||||
}
|
||||
|
||||
// Derive a writable ref per field that reads/writes through the single
|
||||
// shallowRef holding the whole state, matching VueUse's `toRefs(shallowRef)`.
|
||||
const toField = <K extends keyof UsePointerState>(key: K): WritableComputedRef<UsePointerState[K]> =>
|
||||
computed({
|
||||
get: () => state.value[key],
|
||||
set: value => (state.value = { ...state.value, [key]: value }),
|
||||
});
|
||||
|
||||
return {
|
||||
x: toField('x'),
|
||||
y: toField('y'),
|
||||
pressure: toField('pressure'),
|
||||
pointerId: toField('pointerId'),
|
||||
tiltX: toField('tiltX'),
|
||||
tiltY: toField('tiltY'),
|
||||
width: toField('width'),
|
||||
height: toField('height'),
|
||||
twist: toField('twist'),
|
||||
pointerType: toField('pointerType'),
|
||||
isInside,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `true` for an object that is itself an event target (window/document/element)
|
||||
* and should be attached to directly, rather than unwrapped from a ref/getter.
|
||||
*/
|
||||
function isTarget(value: unknown): value is EventTarget {
|
||||
return typeof value === 'object' && value !== null && 'addEventListener' in value;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick } from 'vue';
|
||||
import { usePreferredContrast } from '.';
|
||||
|
||||
function stubMatchMedia(matching: string): void {
|
||||
vi.stubGlobal('matchMedia', vi.fn((media: string) => ({
|
||||
matches: media.includes(matching),
|
||||
media,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
})));
|
||||
}
|
||||
|
||||
describe(usePreferredContrast, () => {
|
||||
beforeEach(() => vi.stubGlobal('matchMedia', undefined));
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it.each([
|
||||
['more'],
|
||||
['less'],
|
||||
['custom'],
|
||||
['no-preference'],
|
||||
] as const)('resolves the "%s" contrast preference', async (preference) => {
|
||||
stubMatchMedia(`prefers-contrast: ${preference}`);
|
||||
|
||||
const scope = effectScope();
|
||||
let contrast: ReturnType<typeof usePreferredContrast>;
|
||||
scope.run(() => {
|
||||
contrast = usePreferredContrast();
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(contrast!.value).toBe(preference);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('prioritizes "more" over the other preferences', async () => {
|
||||
// Match everything; the resolution order must win.
|
||||
vi.stubGlobal('matchMedia', vi.fn((media: string) => ({
|
||||
matches: true,
|
||||
media,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
})));
|
||||
|
||||
const scope = effectScope();
|
||||
let contrast: ReturnType<typeof usePreferredContrast>;
|
||||
scope.run(() => {
|
||||
contrast = usePreferredContrast();
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(contrast!.value).toBe('more');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('falls back to "no-preference" when matchMedia is unsupported (SSR)', async () => {
|
||||
// matchMedia is undefined (stubbed in beforeEach).
|
||||
const scope = effectScope();
|
||||
let contrast: ReturnType<typeof usePreferredContrast>;
|
||||
scope.run(() => {
|
||||
contrast = usePreferredContrast();
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(contrast!.value).toBe('no-preference');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('honors a custom ssrContrast fallback when unsupported', async () => {
|
||||
const scope = effectScope();
|
||||
let contrast: ReturnType<typeof usePreferredContrast>;
|
||||
scope.run(() => {
|
||||
contrast = usePreferredContrast({ ssrContrast: 'more' });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(contrast!.value).toBe('more');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('returns "no-preference" with no window provided', async () => {
|
||||
const scope = effectScope();
|
||||
let contrast: ReturnType<typeof usePreferredContrast>;
|
||||
scope.run(() => {
|
||||
contrast = usePreferredContrast({ window: undefined });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(contrast!.value).toBe('no-preference');
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { computed } from 'vue';
|
||||
import type { ComputedRef } from 'vue';
|
||||
import { useMediaQuery } from '@/composables/browser/useMediaQuery';
|
||||
import type { UseMediaQueryOptions } from '@/composables/browser/useMediaQuery';
|
||||
|
||||
export type ContrastType
|
||||
= 'more' | 'less' | 'custom' | 'no-preference';
|
||||
|
||||
export interface UsePreferredContrastOptions extends UseMediaQueryOptions {
|
||||
/**
|
||||
* The contrast preference assumed during SSR (and the first client render),
|
||||
* before `window.matchMedia` is available, to avoid hydration flicker.
|
||||
*
|
||||
* @default 'no-preference'
|
||||
*/
|
||||
ssrContrast?: ContrastType;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name usePreferredContrast
|
||||
* @category Browser
|
||||
* @description Reactive `prefers-contrast` media query, resolving to the user's
|
||||
* preferred contrast level. SSR-safe with an optional SSR fallback value.
|
||||
*
|
||||
* @param {UsePreferredContrastOptions} [options={}] Options (custom `window`, `ssrContrast`)
|
||||
* @returns {ComputedRef<ContrastType>} Readonly ref of the preferred contrast: `'more' | 'less' | 'custom' | 'no-preference'`
|
||||
*
|
||||
* @example
|
||||
* const contrast = usePreferredContrast();
|
||||
*
|
||||
* @example
|
||||
* // Provide an SSR fallback to avoid hydration flicker
|
||||
* const contrast = usePreferredContrast({ ssrContrast: 'more' });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function usePreferredContrast(
|
||||
options: UsePreferredContrastOptions = {},
|
||||
): ComputedRef<ContrastType> {
|
||||
const { ssrContrast = 'no-preference', ...mediaOptions } = options;
|
||||
|
||||
const isMore = useMediaQuery('(prefers-contrast: more)', mediaOptions);
|
||||
const isLess = useMediaQuery('(prefers-contrast: less)', mediaOptions);
|
||||
const isCustom = useMediaQuery('(prefers-contrast: custom)', mediaOptions);
|
||||
const isNoPreference = useMediaQuery('(prefers-contrast: no-preference)', mediaOptions);
|
||||
|
||||
return computed<ContrastType>(() => {
|
||||
if (isMore.value)
|
||||
return 'more';
|
||||
if (isLess.value)
|
||||
return 'less';
|
||||
if (isCustom.value)
|
||||
return 'custom';
|
||||
// When no `prefers-contrast` query matches we're either on a browser that
|
||||
// does not report a preference, or rendering on the server. Distinguish the
|
||||
// explicit `no-preference` match from the unknown/SSR case via the fallback.
|
||||
if (isNoPreference.value)
|
||||
return 'no-preference';
|
||||
return ssrContrast;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope } from 'vue';
|
||||
import { usePreferredLanguages } from '.';
|
||||
import type * as Types from '@/types';
|
||||
|
||||
type Listener = (event: Event) => void;
|
||||
|
||||
interface StubWindow {
|
||||
navigator: { languages: readonly string[] };
|
||||
addEventListener: (type: string, cb: Listener, options?: unknown) => void;
|
||||
removeEventListener: (type: string, cb: Listener) => void;
|
||||
dispatch: () => void;
|
||||
listenerCount: () => number;
|
||||
lastOptions: () => unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a minimal stub `window` whose `navigator.languages` is mutable and that
|
||||
* records its `languagechange` listeners so we can simulate the browser event.
|
||||
*/
|
||||
function makeWindow(initial: string[]): StubWindow {
|
||||
const listeners = new Set<Listener>();
|
||||
let languages: readonly string[] = initial;
|
||||
let captured: unknown;
|
||||
|
||||
const stub: StubWindow = {
|
||||
navigator: {
|
||||
get languages() {
|
||||
return languages;
|
||||
},
|
||||
},
|
||||
addEventListener(type, cb, options) {
|
||||
if (type === 'languagechange') {
|
||||
listeners.add(cb);
|
||||
captured = options;
|
||||
}
|
||||
},
|
||||
removeEventListener(type, cb) {
|
||||
if (type === 'languagechange') listeners.delete(cb);
|
||||
},
|
||||
dispatch() {
|
||||
for (const cb of listeners) cb(new Event('languagechange'));
|
||||
},
|
||||
listenerCount: () => listeners.size,
|
||||
lastOptions: () => captured,
|
||||
};
|
||||
|
||||
// Allow tests to mutate languages before dispatching.
|
||||
Object.defineProperty(stub, 'setLanguages', {
|
||||
value: (next: string[]) => { languages = next; },
|
||||
});
|
||||
|
||||
return stub;
|
||||
}
|
||||
|
||||
describe(usePreferredLanguages, () => {
|
||||
it('reads the initial navigator.languages', () => {
|
||||
const win = makeWindow(['en-US', 'en']);
|
||||
const scope = effectScope();
|
||||
let langs: ReturnType<typeof usePreferredLanguages>;
|
||||
scope.run(() => {
|
||||
langs = usePreferredLanguages({ window: win as unknown as Window });
|
||||
});
|
||||
|
||||
expect(langs!.value).toEqual(['en-US', 'en']);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('updates on languagechange', () => {
|
||||
const win = makeWindow(['en']);
|
||||
const scope = effectScope();
|
||||
let langs: ReturnType<typeof usePreferredLanguages>;
|
||||
scope.run(() => {
|
||||
langs = usePreferredLanguages({ window: win as unknown as Window });
|
||||
});
|
||||
|
||||
(win as unknown as { setLanguages: (n: string[]) => void }).setLanguages(['fr-FR', 'fr', 'en']);
|
||||
win.dispatch();
|
||||
|
||||
expect(langs!.value).toEqual(['fr-FR', 'fr', 'en']);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('registers the listener with a passive option', () => {
|
||||
const win = makeWindow(['en']);
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
usePreferredLanguages({ window: win as unknown as Window });
|
||||
});
|
||||
|
||||
expect(win.listenerCount()).toBe(1);
|
||||
expect(win.lastOptions()).toEqual({ passive: true });
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('removes the listener when the scope is disposed', () => {
|
||||
const win = makeWindow(['en']);
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
usePreferredLanguages({ window: win as unknown as Window });
|
||||
});
|
||||
|
||||
expect(win.listenerCount()).toBe(1);
|
||||
scope.stop();
|
||||
expect(win.listenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('falls back to ["en"] when no window is available (SSR)', async () => {
|
||||
// `defaultWindow` is import-time captured, so override it via a module mock
|
||||
// to simulate a server environment where no window exists.
|
||||
vi.resetModules();
|
||||
vi.doMock('@/types', async () => {
|
||||
const actual = await vi.importActual<typeof Types>('@/types');
|
||||
return { ...actual, defaultWindow: undefined };
|
||||
});
|
||||
|
||||
const { usePreferredLanguages: ssrUsePreferredLanguages } = await import('.');
|
||||
|
||||
const scope = effectScope();
|
||||
let langs: ReturnType<typeof ssrUsePreferredLanguages>;
|
||||
scope.run(() => {
|
||||
langs = ssrUsePreferredLanguages();
|
||||
});
|
||||
|
||||
expect(langs!.value).toEqual(['en']);
|
||||
scope.stop();
|
||||
|
||||
vi.doUnmock('@/types');
|
||||
vi.resetModules();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ShallowRef } from 'vue';
|
||||
import { shallowRef } from 'vue';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
|
||||
/**
|
||||
* @name usePreferredLanguages
|
||||
* @category Browser
|
||||
* @description Reactive `navigator.languages`. Tracks the user's preferred languages and
|
||||
* updates automatically whenever the browser emits a `languagechange` event.
|
||||
*
|
||||
* Falls back to `['en']` during SSR or when no `window` is available, so the returned
|
||||
* value is always a non-empty array.
|
||||
*
|
||||
* @param {ConfigurableWindow} [options={}] Options
|
||||
* @returns {ShallowRef<readonly string[]>} Reactive list of the user's preferred languages
|
||||
*
|
||||
* @example
|
||||
* const languages = usePreferredLanguages();
|
||||
* // -> ['en-US', 'en', 'fr']
|
||||
*
|
||||
* @example
|
||||
* // Pass a custom window (e.g. an iframe)
|
||||
* const languages = usePreferredLanguages({ window: iframe.contentWindow });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function usePreferredLanguages(options: ConfigurableWindow = {}): ShallowRef<readonly string[]> {
|
||||
const { window = defaultWindow } = options;
|
||||
|
||||
if (!window)
|
||||
return shallowRef<readonly string[]>(['en']);
|
||||
|
||||
const navigator = window.navigator;
|
||||
const value = shallowRef<readonly string[]>(navigator.languages);
|
||||
|
||||
useEventListener(window, 'languagechange', () => {
|
||||
value.value = navigator.languages;
|
||||
}, { passive: true });
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick } from 'vue';
|
||||
import { usePreferredReducedMotion } from '.';
|
||||
|
||||
describe(usePreferredReducedMotion, () => {
|
||||
beforeEach(() => vi.stubGlobal('matchMedia', undefined));
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('resolves to "reduce" when the query matches', async () => {
|
||||
vi.stubGlobal('matchMedia', vi.fn((media: string) => ({
|
||||
matches: media.includes('reduce'),
|
||||
media,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
})));
|
||||
|
||||
const scope = effectScope();
|
||||
let motion: ReturnType<typeof usePreferredReducedMotion>;
|
||||
scope.run(() => {
|
||||
motion = usePreferredReducedMotion();
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(motion!.value).toBe('reduce');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('resolves to "no-preference" when the query does not match', async () => {
|
||||
vi.stubGlobal('matchMedia', vi.fn((media: string) => ({
|
||||
matches: false,
|
||||
media,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
})));
|
||||
|
||||
const scope = effectScope();
|
||||
let motion: ReturnType<typeof usePreferredReducedMotion>;
|
||||
scope.run(() => {
|
||||
motion = usePreferredReducedMotion();
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(motion!.value).toBe('no-preference');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reacts to media query changes', async () => {
|
||||
const listeners = new Set<(event: { matches: boolean }) => void>();
|
||||
let currentMatches = false;
|
||||
|
||||
vi.stubGlobal('matchMedia', vi.fn((media: string) => ({
|
||||
get matches() {
|
||||
return currentMatches;
|
||||
},
|
||||
media,
|
||||
addEventListener: (_: string, handler: (event: { matches: boolean }) => void) => listeners.add(handler),
|
||||
removeEventListener: (_: string, handler: (event: { matches: boolean }) => void) => listeners.delete(handler),
|
||||
})));
|
||||
|
||||
const scope = effectScope();
|
||||
let motion: ReturnType<typeof usePreferredReducedMotion>;
|
||||
scope.run(() => {
|
||||
motion = usePreferredReducedMotion();
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(motion!.value).toBe('no-preference');
|
||||
|
||||
currentMatches = true;
|
||||
listeners.forEach(handler => handler({ matches: true }));
|
||||
await nextTick();
|
||||
|
||||
expect(motion!.value).toBe('reduce');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('falls back to "no-preference" when matchMedia is unsupported (SSR)', async () => {
|
||||
// matchMedia is left undefined from beforeEach.
|
||||
const scope = effectScope();
|
||||
let motion: ReturnType<typeof usePreferredReducedMotion>;
|
||||
scope.run(() => {
|
||||
motion = usePreferredReducedMotion();
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(motion!.value).toBe('no-preference');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('returns "no-preference" when window is explicitly undefined (SSR)', async () => {
|
||||
const scope = effectScope();
|
||||
let motion: ReturnType<typeof usePreferredReducedMotion>;
|
||||
scope.run(() => {
|
||||
motion = usePreferredReducedMotion({ window: undefined });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(motion!.value).toBe('no-preference');
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { computed } from 'vue';
|
||||
import type { ComputedRef } from 'vue';
|
||||
import { useMediaQuery } from '@/composables/browser/useMediaQuery';
|
||||
import type { UseMediaQueryOptions } from '@/composables/browser/useMediaQuery';
|
||||
|
||||
export type ReducedMotionType
|
||||
= | 'reduce'
|
||||
| 'no-preference';
|
||||
|
||||
export type UsePreferredReducedMotionOptions = UseMediaQueryOptions;
|
||||
|
||||
export type UsePreferredReducedMotionReturn = ComputedRef<ReducedMotionType>;
|
||||
|
||||
/**
|
||||
* @name usePreferredReducedMotion
|
||||
* @category Browser
|
||||
* @description Reactive `prefers-reduced-motion` media query, resolving to
|
||||
* `'reduce'` when the user requests reduced motion and `'no-preference'`
|
||||
* otherwise. SSR-safe via {@link useMediaQuery}.
|
||||
*
|
||||
* @param {UsePreferredReducedMotionOptions} [options={}] Options (custom `window`)
|
||||
* @returns {UsePreferredReducedMotionReturn} Readonly ref of the current motion preference
|
||||
*
|
||||
* @example
|
||||
* const motion = usePreferredReducedMotion();
|
||||
* // motion.value === 'reduce' | 'no-preference'
|
||||
*
|
||||
* @example
|
||||
* watchEffect(() => {
|
||||
* transitionDuration.value = motion.value === 'reduce' ? 0 : 200;
|
||||
* });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function usePreferredReducedMotion(
|
||||
options: UsePreferredReducedMotionOptions = {},
|
||||
): UsePreferredReducedMotionReturn {
|
||||
const isReduced = useMediaQuery('(prefers-reduced-motion: reduce)', options);
|
||||
|
||||
return computed<ReducedMotionType>(() => isReduced.value ? 'reduce' : 'no-preference');
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick } from 'vue';
|
||||
import { usePreferredReducedTransparency } from '.';
|
||||
|
||||
describe(usePreferredReducedTransparency, () => {
|
||||
beforeEach(() => vi.stubGlobal('matchMedia', undefined));
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('resolves to "reduce" when the media query matches', async () => {
|
||||
vi.stubGlobal('matchMedia', vi.fn((media: string) => ({
|
||||
matches: media.includes('reduce'),
|
||||
media,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
})));
|
||||
|
||||
const scope = effectScope();
|
||||
let transparency: ReturnType<typeof usePreferredReducedTransparency>;
|
||||
scope.run(() => {
|
||||
transparency = usePreferredReducedTransparency();
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(transparency!.value).toBe('reduce');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('resolves to "no-preference" when the media query does not match', async () => {
|
||||
vi.stubGlobal('matchMedia', vi.fn((media: string) => ({
|
||||
matches: false,
|
||||
media,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
})));
|
||||
|
||||
const scope = effectScope();
|
||||
let transparency: ReturnType<typeof usePreferredReducedTransparency>;
|
||||
scope.run(() => {
|
||||
transparency = usePreferredReducedTransparency();
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(transparency!.value).toBe('no-preference');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('defaults to "no-preference" when matchMedia is unsupported (SSR)', async () => {
|
||||
const scope = effectScope();
|
||||
let transparency: ReturnType<typeof usePreferredReducedTransparency>;
|
||||
scope.run(() => {
|
||||
transparency = usePreferredReducedTransparency({ window: undefined });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(transparency!.value).toBe('no-preference');
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { computed } from 'vue';
|
||||
import type { ComputedRef } from 'vue';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { useMediaQuery } from '@/composables/browser/useMediaQuery';
|
||||
|
||||
export type ReducedTransparencyType
|
||||
= 'reduce' | 'no-preference';
|
||||
|
||||
/**
|
||||
* @name usePreferredReducedTransparency
|
||||
* @category Browser
|
||||
* @description Reactive `prefers-reduced-transparency` media query, resolving to
|
||||
* `'reduce'` or `'no-preference'`. SSR-safe (defaults to `'no-preference'`).
|
||||
*
|
||||
* @param {ConfigurableWindow} [options={}] Options (custom `window`)
|
||||
* @returns {ComputedRef<ReducedTransparencyType>} Readonly ref of the user's transparency preference
|
||||
*
|
||||
* @example
|
||||
* const transparency = usePreferredReducedTransparency();
|
||||
* // transparency.value === 'reduce' | 'no-preference'
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function usePreferredReducedTransparency(
|
||||
options: ConfigurableWindow = {},
|
||||
): ComputedRef<ReducedTransparencyType> {
|
||||
const isReduced = useMediaQuery('(prefers-reduced-transparency: reduce)', options);
|
||||
|
||||
return computed<ReducedTransparencyType>(() => isReduced.value ? 'reduce' : 'no-preference');
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { defineComponent, effectScope, nextTick } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { useRafFn } from '.';
|
||||
|
||||
let rafCallbacks: Array<(time: number) => void> = [];
|
||||
let rafIdCounter = 0;
|
||||
let currentTime = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
rafCallbacks = [];
|
||||
rafIdCounter = 0;
|
||||
currentTime = 0;
|
||||
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: (time: number) => void) => {
|
||||
const id = ++rafIdCounter;
|
||||
rafCallbacks.push(cb);
|
||||
return id;
|
||||
});
|
||||
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function triggerFrame(time: number) {
|
||||
currentTime = time;
|
||||
const cbs = [...rafCallbacks];
|
||||
rafCallbacks = [];
|
||||
cbs.forEach(cb => cb(currentTime));
|
||||
}
|
||||
|
||||
const ComponentStub = defineComponent({
|
||||
props: {
|
||||
callback: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const result = useRafFn(props.callback as any, props.options);
|
||||
return { ...result };
|
||||
},
|
||||
template: '<div>{{ isActive }}</div>',
|
||||
});
|
||||
|
||||
describe(useRafFn, () => {
|
||||
it('starts immediately by default', () => {
|
||||
const callback = vi.fn();
|
||||
const wrapper = mount(ComponentStub, {
|
||||
props: { callback },
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toBe('true');
|
||||
});
|
||||
|
||||
it('does not start when immediate is false', () => {
|
||||
const callback = vi.fn();
|
||||
const wrapper = mount(ComponentStub, {
|
||||
props: {
|
||||
callback,
|
||||
options: { immediate: false },
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toBe('false');
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls the callback on animation frame with delta and timestamp', () => {
|
||||
const callback = vi.fn();
|
||||
mount(ComponentStub, {
|
||||
props: { callback },
|
||||
});
|
||||
|
||||
triggerFrame(100);
|
||||
expect(callback).toHaveBeenCalledWith({ delta: 0, timestamp: 100 });
|
||||
});
|
||||
|
||||
it('provides correct delta between frames', () => {
|
||||
const callback = vi.fn();
|
||||
mount(ComponentStub, {
|
||||
props: { callback },
|
||||
});
|
||||
|
||||
triggerFrame(100);
|
||||
triggerFrame(116.67);
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
expect(callback.mock.calls[1]![0]!.delta).toBeCloseTo(16.67, 1);
|
||||
});
|
||||
|
||||
it('pauses and resumes the loop', async () => {
|
||||
const callback = vi.fn();
|
||||
const wrapper = mount(ComponentStub, {
|
||||
props: { callback },
|
||||
});
|
||||
|
||||
triggerFrame(100);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
wrapper.vm.pause();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).toBe('false');
|
||||
triggerFrame(200);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
wrapper.vm.resume();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).toBe('true');
|
||||
triggerFrame(300);
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('resets delta after resume', () => {
|
||||
const callback = vi.fn();
|
||||
const wrapper = mount(ComponentStub, {
|
||||
props: { callback },
|
||||
});
|
||||
|
||||
triggerFrame(100);
|
||||
wrapper.vm.pause();
|
||||
|
||||
wrapper.vm.resume();
|
||||
triggerFrame(500);
|
||||
|
||||
// After resume, first frame delta resets to 0
|
||||
const lastCall = callback.mock.calls[callback.mock.calls.length - 1]![0]!;
|
||||
expect(lastCall.delta).toBe(0);
|
||||
expect(lastCall.timestamp).toBe(500);
|
||||
});
|
||||
|
||||
it('toggles the loop', async () => {
|
||||
const callback = vi.fn();
|
||||
const wrapper = mount(ComponentStub, {
|
||||
props: { callback },
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toBe('true');
|
||||
|
||||
wrapper.vm.toggle();
|
||||
await nextTick();
|
||||
expect(wrapper.text()).toBe('false');
|
||||
|
||||
wrapper.vm.toggle();
|
||||
await nextTick();
|
||||
expect(wrapper.text()).toBe('true');
|
||||
});
|
||||
|
||||
it('limits frame rate with fpsLimit', () => {
|
||||
const callback = vi.fn();
|
||||
mount(ComponentStub, {
|
||||
props: {
|
||||
callback,
|
||||
options: { fpsLimit: 30 },
|
||||
},
|
||||
});
|
||||
|
||||
// First frame always fires (delta is 0)
|
||||
triggerFrame(100);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
// 30fps = ~33.33ms per frame — too soon, skipped
|
||||
triggerFrame(110);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Enough time passed (~40ms > 33.33ms)
|
||||
triggerFrame(140);
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('cleans up on scope dispose', () => {
|
||||
const callback = vi.fn();
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
useRafFn(callback);
|
||||
});
|
||||
|
||||
triggerFrame(100);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
scope.stop();
|
||||
triggerFrame(200);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('cleans up on component unmount', () => {
|
||||
const callback = vi.fn();
|
||||
const wrapper = mount(ComponentStub, {
|
||||
props: { callback },
|
||||
});
|
||||
|
||||
triggerFrame(100);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
wrapper.unmount();
|
||||
triggerFrame(200);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does nothing when window is undefined (SSR)', () => {
|
||||
const callback = vi.fn();
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
const { isActive } = useRafFn(callback, { window: undefined as any });
|
||||
expect(isActive.value).toBeFalsy();
|
||||
});
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('resume is idempotent when already active', () => {
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useRafFn>;
|
||||
|
||||
scope.run(() => {
|
||||
result = useRafFn(vi.fn());
|
||||
});
|
||||
|
||||
expect(result!.isActive.value).toBeTruthy();
|
||||
result!.resume();
|
||||
expect(result!.isActive.value).toBeTruthy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('pause is idempotent when already paused', () => {
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useRafFn>;
|
||||
|
||||
scope.run(() => {
|
||||
result = useRafFn(vi.fn(), { immediate: false });
|
||||
});
|
||||
|
||||
expect(result!.isActive.value).toBeFalsy();
|
||||
result!.pause();
|
||||
expect(result!.isActive.value).toBeFalsy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
import { readonly, ref } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow, ResumableActions, ResumableOptions } from '@/types';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
|
||||
export interface UseRafFnCallbackArgs {
|
||||
/**
|
||||
* Time elapsed since the last frame in milliseconds
|
||||
*/
|
||||
delta: number;
|
||||
|
||||
/**
|
||||
* `DOMHighResTimeStamp` passed by `requestAnimationFrame`
|
||||
*/
|
||||
timestamp: DOMHighResTimeStamp;
|
||||
}
|
||||
|
||||
export interface UseRafFnOptions extends ResumableOptions, ConfigurableWindow {
|
||||
/**
|
||||
* Maximum frames per second. Set to `0` or `undefined` to disable the limit.
|
||||
*
|
||||
* @default undefined
|
||||
*/
|
||||
fpsLimit?: number;
|
||||
}
|
||||
|
||||
export interface UseRafFnReturn extends ResumableActions {
|
||||
/**
|
||||
* Whether the RAF loop is currently active
|
||||
*/
|
||||
isActive: Readonly<Ref<boolean>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a function on every `requestAnimationFrame` with delta time tracking.
|
||||
* Automatically cleans up when the component scope is disposed.
|
||||
*
|
||||
* @param callback - Function to call on every animation frame
|
||||
* @param options - Configuration options
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const { pause, resume, isActive } = useRafFn(({ delta, timestamp }) => {
|
||||
* console.log(`${delta}ms since last frame`);
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function useRafFn(
|
||||
callback: (args: UseRafFnCallbackArgs) => void,
|
||||
options: UseRafFnOptions = {},
|
||||
): UseRafFnReturn {
|
||||
const {
|
||||
immediate = true,
|
||||
fpsLimit,
|
||||
} = options;
|
||||
|
||||
const window = 'window' in options ? options.window : defaultWindow;
|
||||
|
||||
const isActive = ref(false);
|
||||
const intervalLimit = fpsLimit ? 1000 / fpsLimit : null;
|
||||
|
||||
let previousFrameTimestamp = 0;
|
||||
let rafId: number | null = null;
|
||||
|
||||
function loop(timestamp: DOMHighResTimeStamp) {
|
||||
if (!isActive.value || !window)
|
||||
return;
|
||||
|
||||
if (!previousFrameTimestamp)
|
||||
previousFrameTimestamp = timestamp;
|
||||
|
||||
const delta = timestamp - previousFrameTimestamp;
|
||||
|
||||
if (intervalLimit && delta && delta < intervalLimit) {
|
||||
rafId = window.requestAnimationFrame(loop);
|
||||
return;
|
||||
}
|
||||
|
||||
previousFrameTimestamp = timestamp;
|
||||
callback({ delta, timestamp });
|
||||
rafId = window.requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
function resume() {
|
||||
if (!isActive.value && window) {
|
||||
isActive.value = true;
|
||||
previousFrameTimestamp = 0;
|
||||
rafId = window.requestAnimationFrame(loop);
|
||||
}
|
||||
}
|
||||
|
||||
function pause() {
|
||||
isActive.value = false;
|
||||
|
||||
if (rafId !== null && window) {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (isActive.value)
|
||||
pause();
|
||||
else
|
||||
resume();
|
||||
}
|
||||
|
||||
if (immediate)
|
||||
resume();
|
||||
|
||||
tryOnScopeDispose(pause);
|
||||
|
||||
return {
|
||||
isActive: readonly(isActive),
|
||||
pause,
|
||||
resume,
|
||||
toggle,
|
||||
};
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick, ref } from 'vue';
|
||||
import { useResizeObserver } from '.';
|
||||
|
||||
let instances: Array<{ cb: ResizeObserverCallback; observe: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn> }> = [];
|
||||
|
||||
class StubResizeObserver {
|
||||
observe = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
unobserve = vi.fn();
|
||||
cb: ResizeObserverCallback;
|
||||
constructor(cb: ResizeObserverCallback) {
|
||||
this.cb = cb;
|
||||
instances.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
describe(useResizeObserver, () => {
|
||||
beforeEach(() => {
|
||||
instances = [];
|
||||
vi.stubGlobal('ResizeObserver', StubResizeObserver);
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('observes the target element', () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
scope.run(() => useResizeObserver(ref(el), vi.fn()));
|
||||
|
||||
expect(instances).toHaveLength(1);
|
||||
expect(instances[0]!.observe).toHaveBeenCalledWith(el, undefined);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('passes the box option through to observe', () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
scope.run(() => useResizeObserver(ref(el), vi.fn(), { box: 'border-box' }));
|
||||
|
||||
expect(instances[0]!.observe).toHaveBeenCalledWith(el, { box: 'border-box' });
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('observes an array of targets with a single observer', () => {
|
||||
const a = document.createElement('div');
|
||||
const b = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
scope.run(() => useResizeObserver([ref(a), ref(b)], vi.fn()));
|
||||
|
||||
expect(instances).toHaveLength(1);
|
||||
expect(instances[0]!.observe).toHaveBeenCalledWith(a, undefined);
|
||||
expect(instances[0]!.observe).toHaveBeenCalledWith(b, undefined);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('supports a getter target', () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
scope.run(() => useResizeObserver(() => el, vi.fn()));
|
||||
|
||||
expect(instances).toHaveLength(1);
|
||||
expect(instances[0]!.observe).toHaveBeenCalledWith(el, undefined);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('disconnects on stop', () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let stop: () => void;
|
||||
scope.run(() => {
|
||||
stop = useResizeObserver(ref(el), vi.fn()).stop;
|
||||
});
|
||||
|
||||
stop!();
|
||||
expect(instances[0]!.disconnect).toHaveBeenCalled();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('invokes the callback with entries', () => {
|
||||
const el = document.createElement('div');
|
||||
const callback = vi.fn();
|
||||
const scope = effectScope();
|
||||
scope.run(() => useResizeObserver(ref(el), callback));
|
||||
|
||||
const entry = { contentRect: { width: 10, height: 20 } } as ResizeObserverEntry;
|
||||
instances[0]!.cb([entry], instances[0] as unknown as ResizeObserver);
|
||||
expect(callback).toHaveBeenCalledWith([entry], expect.anything());
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('re-observes when the target ref changes', async () => {
|
||||
const a = document.createElement('div');
|
||||
const b = document.createElement('div');
|
||||
const target = ref<HTMLElement>(a);
|
||||
const scope = effectScope();
|
||||
scope.run(() => useResizeObserver(target, vi.fn()));
|
||||
|
||||
expect(instances).toHaveLength(1);
|
||||
expect(instances[0]!.observe).toHaveBeenCalledWith(a, undefined);
|
||||
|
||||
target.value = b;
|
||||
await nextTick();
|
||||
|
||||
expect(instances[0]!.disconnect).toHaveBeenCalled();
|
||||
expect(instances).toHaveLength(2);
|
||||
expect(instances[1]!.observe).toHaveBeenCalledWith(b, undefined);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not create an observer for a null target', () => {
|
||||
const target = ref<HTMLElement | null>(null);
|
||||
const scope = effectScope();
|
||||
scope.run(() => useResizeObserver(target, vi.fn()));
|
||||
|
||||
expect(instances).toHaveLength(0);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('starts observing when a null target is later assigned', async () => {
|
||||
const el = document.createElement('div');
|
||||
const target = ref<HTMLElement | null>(null);
|
||||
const scope = effectScope();
|
||||
scope.run(() => useResizeObserver(target, vi.fn()));
|
||||
|
||||
expect(instances).toHaveLength(0);
|
||||
|
||||
target.value = el;
|
||||
await nextTick();
|
||||
|
||||
expect(instances).toHaveLength(1);
|
||||
expect(instances[0]!.observe).toHaveBeenCalledWith(el, undefined);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not observe when immediate is false until resumed', async () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let controls!: ReturnType<typeof useResizeObserver>;
|
||||
scope.run(() => {
|
||||
controls = useResizeObserver(ref(el), vi.fn(), { immediate: false });
|
||||
});
|
||||
|
||||
expect(controls.isActive.value).toBeFalsy();
|
||||
expect(instances).toHaveLength(0);
|
||||
|
||||
controls.resume();
|
||||
await nextTick();
|
||||
|
||||
expect(controls.isActive.value).toBeTruthy();
|
||||
expect(instances).toHaveLength(1);
|
||||
expect(instances[0]!.observe).toHaveBeenCalledWith(el, undefined);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('pause disconnects and flips isActive, resume re-observes', async () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let controls!: ReturnType<typeof useResizeObserver>;
|
||||
scope.run(() => {
|
||||
controls = useResizeObserver(ref(el), vi.fn());
|
||||
});
|
||||
|
||||
expect(controls.isActive.value).toBeTruthy();
|
||||
expect(instances).toHaveLength(1);
|
||||
|
||||
controls.pause();
|
||||
expect(controls.isActive.value).toBeFalsy();
|
||||
expect(instances[0]!.disconnect).toHaveBeenCalled();
|
||||
|
||||
controls.resume();
|
||||
await nextTick();
|
||||
|
||||
expect(controls.isActive.value).toBeTruthy();
|
||||
expect(instances).toHaveLength(2);
|
||||
expect(instances[1]!.observe).toHaveBeenCalledWith(el, undefined);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('cleans up when the scope is disposed', () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
scope.run(() => useResizeObserver(ref(el), vi.fn()));
|
||||
|
||||
expect(instances).toHaveLength(1);
|
||||
scope.stop();
|
||||
expect(instances[0]!.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,149 +0,0 @@
|
||||
import { computed, readonly, ref, watch } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import { toArray } from '@robonen/stdlib';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
||||
import { unrefElement } from '@/composables/component/unrefElement';
|
||||
import { useSupported } from '@/composables/browser/useSupported';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
|
||||
export interface UseResizeObserverOptions extends ConfigurableWindow {
|
||||
/**
|
||||
* The box model to observe
|
||||
*
|
||||
* @default 'content-box'
|
||||
*/
|
||||
box?: ResizeObserverBoxOptions;
|
||||
|
||||
/**
|
||||
* Start observing immediately once the target is resolved
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
immediate?: boolean;
|
||||
}
|
||||
|
||||
export type ResizeObserverCallback = (
|
||||
entries: readonly ResizeObserverEntry[],
|
||||
observer: ResizeObserver,
|
||||
) => void;
|
||||
|
||||
export interface UseResizeObserverReturn {
|
||||
/**
|
||||
* Whether `ResizeObserver` is supported in the current environment
|
||||
*/
|
||||
isSupported: Readonly<Ref<boolean>>;
|
||||
|
||||
/**
|
||||
* Whether the observer is currently active
|
||||
*/
|
||||
isActive: Readonly<Ref<boolean>>;
|
||||
|
||||
/**
|
||||
* Temporarily stop observing (disconnects the observer) while keeping the
|
||||
* target watcher alive, so observing can be resumed later
|
||||
*/
|
||||
pause: () => void;
|
||||
|
||||
/**
|
||||
* Resume observing after a `pause`
|
||||
*/
|
||||
resume: () => void;
|
||||
|
||||
/**
|
||||
* Permanently stop observing and tear down the target watcher
|
||||
*/
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useResizeObserver
|
||||
* @category Browser
|
||||
* @description Reports changes to the dimensions of an element via `ResizeObserver`.
|
||||
* Accepts a single target or an array of (reactive) targets. The observer is
|
||||
* recreated only when the resolved elements change, and can be paused/resumed.
|
||||
*
|
||||
* @param {MaybeComputedElementRef | MaybeComputedElementRef[]} target Element(s) to observe
|
||||
* @param {ResizeObserverCallback} callback Invoked with the observer entries
|
||||
* @param {UseResizeObserverOptions} [options={}] Options
|
||||
* @returns {UseResizeObserverReturn} `isSupported`, `isActive`, `pause`, `resume`, and `stop`
|
||||
*
|
||||
* @example
|
||||
* useResizeObserver(el, ([entry]) => {
|
||||
* console.log(entry.contentRect.width);
|
||||
* });
|
||||
*
|
||||
* @example
|
||||
* const { pause, resume } = useResizeObserver([el1, el2], (entries) => {
|
||||
* // react to multiple targets
|
||||
* }, { box: 'border-box' });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useResizeObserver(
|
||||
target: MaybeComputedElementRef | MaybeComputedElementRef[],
|
||||
callback: ResizeObserverCallback,
|
||||
options: UseResizeObserverOptions = {},
|
||||
): UseResizeObserverReturn {
|
||||
const { window = defaultWindow, box, immediate = true } = options;
|
||||
|
||||
const isSupported = useSupported(() => window && 'ResizeObserver' in window);
|
||||
|
||||
// Cache the observer options object so it is not rebuilt on every observe call
|
||||
const observerOptions: ResizeObserverOptions | undefined = box ? { box } : undefined;
|
||||
|
||||
const isActive = ref(immediate);
|
||||
|
||||
let observer: ResizeObserver | undefined;
|
||||
|
||||
const targets = computed(() => {
|
||||
return toArray(target).map(el => unrefElement(el)).filter((el): el is Element => Boolean(el));
|
||||
});
|
||||
|
||||
const cleanup = () => {
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
observer = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const stopWatch = watch(
|
||||
() => [targets.value, isActive.value] as const,
|
||||
([els, active]) => {
|
||||
cleanup();
|
||||
|
||||
if (!active || !isSupported.value || !window || !els.length)
|
||||
return;
|
||||
|
||||
observer = new ResizeObserver(callback);
|
||||
for (const el of els)
|
||||
observer.observe(el, observerOptions);
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
);
|
||||
|
||||
const resume = () => {
|
||||
isActive.value = true;
|
||||
};
|
||||
|
||||
const pause = () => {
|
||||
cleanup();
|
||||
isActive.value = false;
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
cleanup();
|
||||
stopWatch();
|
||||
};
|
||||
|
||||
tryOnScopeDispose(stop);
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
isActive: readonly(isActive),
|
||||
pause,
|
||||
resume,
|
||||
stop,
|
||||
};
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick } from 'vue';
|
||||
import { useScreenOrientation } from '.';
|
||||
import type { OrientationLockType, OrientationType } from '.';
|
||||
|
||||
type Listener = (event: Event) => void;
|
||||
|
||||
interface StubScreenOrientation {
|
||||
type: OrientationType;
|
||||
angle: number;
|
||||
lock: ReturnType<typeof vi.fn>;
|
||||
unlock: ReturnType<typeof vi.fn>;
|
||||
addEventListener: (type: string, cb: Listener) => void;
|
||||
removeEventListener: (type: string, cb: Listener) => void;
|
||||
dispatch: (type: OrientationType, angle: number) => void;
|
||||
}
|
||||
|
||||
function makeScreenOrientation(
|
||||
type: OrientationType = 'portrait-primary',
|
||||
angle = 0,
|
||||
): StubScreenOrientation {
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
const so: StubScreenOrientation = {
|
||||
type,
|
||||
angle,
|
||||
lock: vi.fn(() => Promise.resolve()),
|
||||
unlock: vi.fn(),
|
||||
addEventListener: (_: string, cb: Listener) => listeners.add(cb),
|
||||
removeEventListener: (_: string, cb: Listener) => listeners.delete(cb),
|
||||
dispatch(nextType: OrientationType, nextAngle: number) {
|
||||
so.type = nextType;
|
||||
so.angle = nextAngle;
|
||||
for (const cb of listeners) cb(new Event('change'));
|
||||
},
|
||||
};
|
||||
|
||||
return so;
|
||||
}
|
||||
|
||||
/** A window stub exposing `screen.orientation` + an event target for `orientationchange`. */
|
||||
function makeWindowStub(so?: StubScreenOrientation): Window {
|
||||
const winListeners = new Set<Listener>();
|
||||
return {
|
||||
screen: so ? { orientation: so } : {},
|
||||
addEventListener: (_: string, cb: Listener) => winListeners.add(cb),
|
||||
removeEventListener: (_: string, cb: Listener) => winListeners.delete(cb),
|
||||
} as unknown as Window;
|
||||
}
|
||||
|
||||
describe(useScreenOrientation, () => {
|
||||
beforeEach(() => {
|
||||
// Force the SSR / unsupported branch unless a window is passed via options.
|
||||
vi.stubGlobal('screen', undefined);
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('reports supported when screen.orientation is present', async () => {
|
||||
const window = makeWindowStub(makeScreenOrientation());
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useScreenOrientation>;
|
||||
scope.run(() => {
|
||||
result = useScreenOrientation({ window });
|
||||
});
|
||||
await nextTick();
|
||||
expect(result!.isSupported.value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reflects the initial orientation type and angle', async () => {
|
||||
const so = makeScreenOrientation('landscape-primary', 90);
|
||||
const window = makeWindowStub(so);
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useScreenOrientation>;
|
||||
scope.run(() => {
|
||||
result = useScreenOrientation({ window });
|
||||
});
|
||||
await nextTick();
|
||||
expect(result!.orientation.value).toBe('landscape-primary');
|
||||
expect(result!.angle.value).toBe(90);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('updates on the screen.orientation change event', async () => {
|
||||
const so = makeScreenOrientation('portrait-primary', 0);
|
||||
const window = makeWindowStub(so);
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useScreenOrientation>;
|
||||
scope.run(() => {
|
||||
result = useScreenOrientation({ window });
|
||||
});
|
||||
await nextTick();
|
||||
expect(result!.orientation.value).toBe('portrait-primary');
|
||||
|
||||
so.dispatch('landscape-secondary', 270);
|
||||
await nextTick();
|
||||
expect(result!.orientation.value).toBe('landscape-secondary');
|
||||
expect(result!.angle.value).toBe(270);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('lockOrientation delegates to screen.orientation.lock', async () => {
|
||||
const so = makeScreenOrientation();
|
||||
const window = makeWindowStub(so);
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useScreenOrientation>;
|
||||
scope.run(() => {
|
||||
result = useScreenOrientation({ window });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
const type: OrientationLockType = 'landscape';
|
||||
await expect(result!.lockOrientation(type)).resolves.toBeUndefined();
|
||||
expect(so.lock).toHaveBeenCalledWith('landscape');
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('unlockOrientation delegates to screen.orientation.unlock', async () => {
|
||||
const so = makeScreenOrientation();
|
||||
const window = makeWindowStub(so);
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useScreenOrientation>;
|
||||
scope.run(() => {
|
||||
result = useScreenOrientation({ window });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
result!.unlockOrientation();
|
||||
expect(so.unlock).toHaveBeenCalledTimes(1);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('stops updating after the scope is disposed', async () => {
|
||||
const so = makeScreenOrientation('portrait-primary', 0);
|
||||
const window = makeWindowStub(so);
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useScreenOrientation>;
|
||||
scope.run(() => {
|
||||
result = useScreenOrientation({ window });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
scope.stop();
|
||||
|
||||
so.dispatch('landscape-primary', 90);
|
||||
await nextTick();
|
||||
// Listener removed on dispose, so the value should not have changed.
|
||||
expect(result!.orientation.value).toBe('portrait-primary');
|
||||
expect(result!.angle.value).toBe(0);
|
||||
});
|
||||
|
||||
it('is unsupported and safe when no window is available (SSR)', async () => {
|
||||
// Pass an explicit falsy window: a destructuring default only fires for
|
||||
// `undefined`, so `null` lets us exercise the genuine no-window branch.
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useScreenOrientation>;
|
||||
scope.run(() => {
|
||||
result = useScreenOrientation({ window: null as unknown as Window });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(result!.isSupported.value).toBeFalsy();
|
||||
expect(result!.orientation.value).toBeUndefined();
|
||||
expect(result!.angle.value).toBe(0);
|
||||
expect(() => result!.unlockOrientation()).not.toThrow();
|
||||
await expect(result!.lockOrientation('any')).rejects.toThrow('Not supported');
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('is unsupported when screen lacks orientation', async () => {
|
||||
const window = makeWindowStub();
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useScreenOrientation>;
|
||||
scope.run(() => {
|
||||
result = useScreenOrientation({ window });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(result!.isSupported.value).toBeFalsy();
|
||||
await expect(result!.lockOrientation('portrait')).rejects.toThrow('Not supported');
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
import { shallowRef } from 'vue';
|
||||
import type { ComputedRef, ShallowRef } from 'vue';
|
||||
import { isFunction } from '@robonen/stdlib';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
import { useSupported } from '@/composables/browser/useSupported';
|
||||
|
||||
export type OrientationType
|
||||
= | 'portrait-primary'
|
||||
| 'portrait-secondary'
|
||||
| 'landscape-primary'
|
||||
| 'landscape-secondary';
|
||||
|
||||
export type OrientationLockType
|
||||
= | 'any'
|
||||
| 'natural'
|
||||
| 'landscape'
|
||||
| 'portrait'
|
||||
| 'portrait-primary'
|
||||
| 'portrait-secondary'
|
||||
| 'landscape-primary'
|
||||
| 'landscape-secondary';
|
||||
|
||||
/**
|
||||
* Subset of the `ScreenOrientation` interface that we interact with.
|
||||
*/
|
||||
export interface ScreenOrientation extends EventTarget {
|
||||
readonly type: OrientationType;
|
||||
readonly angle: number;
|
||||
lock: (orientation: OrientationLockType) => Promise<void>;
|
||||
unlock: () => void;
|
||||
}
|
||||
|
||||
export interface UseScreenOrientationOptions extends ConfigurableWindow {}
|
||||
|
||||
export interface UseScreenOrientationReturn {
|
||||
/**
|
||||
* Whether the Screen Orientation API is supported
|
||||
*/
|
||||
isSupported: ComputedRef<boolean>;
|
||||
/**
|
||||
* Current screen orientation type, or `undefined` when unsupported
|
||||
*/
|
||||
orientation: ShallowRef<OrientationType | undefined>;
|
||||
/**
|
||||
* Current screen orientation angle in degrees (defaults to `0`)
|
||||
*/
|
||||
angle: ShallowRef<number>;
|
||||
/**
|
||||
* Lock the screen to the given orientation. Rejects when unsupported.
|
||||
*/
|
||||
lockOrientation: (type: OrientationLockType) => Promise<void>;
|
||||
/**
|
||||
* Release a previously applied orientation lock. No-op when unsupported.
|
||||
*/
|
||||
unlockOrientation: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useScreenOrientation
|
||||
* @category Browser
|
||||
* @description Reactive Screen Orientation API. Tracks the current orientation
|
||||
* `type` and `angle`, and exposes helpers to lock/unlock the orientation. SSR-safe.
|
||||
*
|
||||
* @param {UseScreenOrientationOptions} [options={}] Options (custom `window`)
|
||||
* @returns {UseScreenOrientationReturn} `{ isSupported, orientation, angle, lockOrientation, unlockOrientation }`
|
||||
*
|
||||
* @example
|
||||
* const { isSupported, orientation, angle, lockOrientation, unlockOrientation } = useScreenOrientation();
|
||||
*
|
||||
* @example
|
||||
* // Lock to landscape (must run from a user gesture / fullscreen context)
|
||||
* const { lockOrientation } = useScreenOrientation();
|
||||
* await lockOrientation('landscape');
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useScreenOrientation(options: UseScreenOrientationOptions = {}): UseScreenOrientationReturn {
|
||||
const { window = defaultWindow } = options;
|
||||
|
||||
const isSupported = useSupported(() =>
|
||||
Boolean(window && 'screen' in window && window.screen && 'orientation' in window.screen));
|
||||
|
||||
const screenOrientation = (isSupported.value ? window!.screen.orientation : {}) as ScreenOrientation;
|
||||
|
||||
const orientation = shallowRef<OrientationType | undefined>(screenOrientation.type);
|
||||
const angle = shallowRef(screenOrientation.angle || 0);
|
||||
|
||||
const update = (): void => {
|
||||
orientation.value = screenOrientation.type;
|
||||
angle.value = screenOrientation.angle;
|
||||
};
|
||||
|
||||
if (isSupported.value) {
|
||||
// The standard `change` event fires on the `ScreenOrientation` object itself;
|
||||
// `orientationchange` on `window` is the legacy fallback for older engines.
|
||||
useEventListener(screenOrientation, 'change', update, { passive: true });
|
||||
useEventListener(window, 'orientationchange', update, { passive: true });
|
||||
}
|
||||
|
||||
const lockOrientation = (type: OrientationLockType): Promise<void> => {
|
||||
if (isSupported.value && isFunction(screenOrientation.lock))
|
||||
return screenOrientation.lock(type);
|
||||
|
||||
return Promise.reject(new Error('Not supported'));
|
||||
};
|
||||
|
||||
const unlockOrientation = (): void => {
|
||||
if (isSupported.value && isFunction(screenOrientation.unlock))
|
||||
screenOrientation.unlock();
|
||||
};
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
orientation,
|
||||
angle,
|
||||
lockOrientation,
|
||||
unlockOrientation,
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user