fix(vue): eslint/tsconfig migration + resolve type errors

@robonen/vue (toolkit): migrate to eslint flat config + composite tsconfig;
fix composable + test type errors (writable computed returns, null guards,
overload-compatible signatures, typed test helpers) — all type-level.
This commit is contained in:
2026-06-07 16:29:39 +07:00
parent e6919de29e
commit c7644ade69
203 changed files with 23016 additions and 141 deletions
+21 -8
View File
@@ -1,6 +1,6 @@
# @robonen/vue # @robonen/vue
Collection of composables and utilities for Vue 3. Collection of composables and utilities for Vue 3 — 100+ tree-shakeable, SSR-safe composables.
## Install ## Install
@@ -11,18 +11,31 @@ pnpm install @robonen/vue
## Composables ## Composables
| Category | Composables | | Category | Composables |
| -------------- | ------------------------------------------------------------------ | | -------------- | ----------- |
| **browser** | `useEventListener`, `useFocusGuard`, `useSupported` | | **browser** | `onKeyStroke`, `useActiveElement`, `useBodyScrollLock`, `useClickOutside`, `useClipboard`, `useCloseWatcher`, `useColorMode`, `useDevicePixelRatio`, `useDocumentReadyState`, `useDocumentVisibility`, `useDropZone`, `useElementBounding`, `useElementHover`, `useElementSize`, `useElementVisibility`, `useEscapeKey`, `useEventListener`, `useEyeDropper`, `useFavicon`, `useFileDialog`, `useFocus`, `useFocusGuard`, `useFocusWithin`, `useFps`, `useFullscreen`, `useGeolocation`, `useIdle`, `useIntersectionObserver`, `useIntervalFn`, `useKeyModifier`, `useMagicKeys`, `useMediaQuery`, `useMouse`, `useMousePressed`, `useMutationObserver`, `useNetwork`, `useObjectUrl`, `useOnline`, `usePageLeave`, `usePermission`, `usePointer`, `usePreferredColorScheme`, `usePreferredDark`, `useRafFn`, `useResizeObserver`, `useScreenOrientation`, `useScroll`, `useScrollLock`, `useShare`, `useSupported`, `useSwipe`, `useTabLeader`, `useTextSelection`, `useTitle`, `useVibrate`, `useWindowFocus`, `useWindowScroll`, `useWindowSize` |
| **component** | `unrefElement`, `useRenderCount`, `useRenderInfo` | | **component** | `unrefElement`, `useForwardExpose`, `useTemplateRefsList` |
| **debug** | `useRenderCount`, `useRenderInfo` |
| **lifecycle** | `tryOnBeforeMount`, `tryOnMounted`, `tryOnScopeDispose`, `useMounted` | | **lifecycle** | `tryOnBeforeMount`, `tryOnMounted`, `tryOnScopeDispose`, `useMounted` |
| **math** | `useClamp` | | **math** | `useClamp` |
| **reactivity** | `broadcastedRef`, `useCached`, `useLastChanged`, `useSyncRefs` | | **reactivity** | `broadcastedRef`, `refAutoReset`, `refDebounced`, `refThrottled`, `until`, `useArrayFilter`, `useArrayFind`, `useArrayMap`, `useCached`, `useCloned`, `useCycleList`, `useLastChanged`, `usePrevious`, `useSyncRefs`, `useToNumber`, `useToString`, `watchDebounced`, `watchIgnorable`, `watchOnce`, `watchPausable`, `watchThrottled`, `whenever` |
| **state** | `useAppSharedState`, `useAsyncState`, `useContextFactory`, `useCounter`, `useInjectionStore`, `useToggle` | | **state** | `useAppSharedState`, `useAsyncState`, `useContextFactory`, `useCounter`, `useId`, `useInjectionStore`, `useStepper`, `useToggle` |
| **storage** | `useLocalStorage`, `useSessionStorage`, `useStorage`, `useStorageAsync` | | **storage** | `useLocalStorage`, `useSessionStorage`, `useStorage`, `useStorageAsync` |
| **utilities** | `useOffsetPagination` | | **utilities** | `useDebounceFn`, `useInterval`, `useOffsetPagination`, `useThrottleFn`, `useTimeoutFn`, `useTimestamp` |
The package also exports event-filter helpers (`debounceFilter`, `throttleFilter`, `pausableFilter`, `createFilterWrapper`) and shared types (`ConfigurableWindow`, `ConfigurableDocument`, `ConfigurableNavigator`, `MaybeComputedElementRef`, …).
## Usage ## Usage
```ts ```ts
import { useToggle, useEventListener } from '@robonen/vue'; import { useEventListener, useMagicKeys, useToggle } from '@robonen/vue';
const { value, toggle } = useToggle();
useEventListener('scroll', () => {/* … */}, { passive: true });
const keys = useMagicKeys();
watchEffect(() => {
if (keys['ctrl+s'].value)
save();
});
``` ```
+3
View File
@@ -0,0 +1,3 @@
import { base, compose, imports, stylistic, typescript, vitest, vue } from '@robonen/eslint';
export default compose(base, typescript, vue, vitest, imports, stylistic);
-4
View File
@@ -1,4 +0,0 @@
import { defineConfig } from 'oxlint';
import { compose, base, typescript, vue, vitest, imports, stylistic } from '@robonen/oxlint';
export default defineConfig(compose(base, typescript, vue, vitest, imports, stylistic));
+4 -5
View File
@@ -37,19 +37,18 @@
} }
}, },
"scripts": { "scripts": {
"lint:check": "oxlint -c oxlint.config.ts", "lint:check": "eslint .",
"lint:fix": "oxlint -c oxlint.config.ts --fix", "lint:fix": "eslint . --fix",
"test": "vitest run", "test": "vitest run",
"dev": "vitest dev", "dev": "vitest dev",
"build": "tsdown" "build": "tsdown"
}, },
"devDependencies": { "devDependencies": {
"@robonen/oxlint": "workspace:*", "@robonen/eslint": "workspace:*",
"@robonen/tsconfig": "workspace:*", "@robonen/tsconfig": "workspace:*",
"@robonen/tsdown": "workspace:*", "@robonen/tsdown": "workspace:*",
"@stylistic/eslint-plugin": "catalog:",
"@vue/test-utils": "catalog:", "@vue/test-utils": "catalog:",
"oxlint": "catalog:", "eslint": "catalog:",
"tsdown": "catalog:" "tsdown": "catalog:"
}, },
"dependencies": { "dependencies": {
@@ -1,7 +1,58 @@
export * from './onKeyStroke';
export * from './useActiveElement';
export * from './useBodyScrollLock';
export * from './useClickOutside';
export * from './useClipboard';
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 './useEventListener'; export * from './useEventListener';
export * from './useEyeDropper';
export * from './useFavicon';
export * from './useFileDialog';
export * from './useFocus';
export * from './useFocusGuard'; export * from './useFocusGuard';
export * from './useFocusWithin';
export * from './useFps'; export * from './useFps';
export * from './useFullscreen';
export * from './useGeolocation';
export * from './useIdle';
export * from './useIntersectionObserver';
export * from './useIntervalFn'; export * from './useIntervalFn';
export * from './useKeyModifier';
export * from './useMagicKeys';
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 './usePreferredDark';
export * from './useRafFn'; export * from './useRafFn';
export * from './useResizeObserver';
export * from './useScreenOrientation';
export * from './useScroll';
export * from './useScrollLock';
export * from './useShare';
export * from './useSupported'; export * from './useSupported';
export * from './useSwipe';
export * from './useTabLeader'; export * from './useTabLeader';
export * from './useTextSelection';
export * from './useTitle';
export * from './useVibrate';
export * from './useWindowFocus';
export * from './useWindowScroll';
export * from './useWindowSize';
@@ -0,0 +1,237 @@
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);
});
});
@@ -0,0 +1,197 @@
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' });
}
@@ -0,0 +1,207 @@
import { describe, expect, it } from 'vitest';
import { effectScope, isReadonly, nextTick } from 'vue';
import { useActiveElement } from '.';
describe(useActiveElement, () => {
it('tracks the focused element', async () => {
const input = document.createElement('input');
document.body.appendChild(input);
const scope = effectScope();
let active: ReturnType<typeof useActiveElement>;
scope.run(() => {
active = useActiveElement();
});
input.focus();
input.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
await nextTick();
expect(active!.value).toBe(input);
scope.stop();
input.remove();
});
it('returns a shallow ref (not readonly)', () => {
const scope = effectScope();
let active: ReturnType<typeof useActiveElement>;
scope.run(() => {
active = useActiveElement();
});
// shallowRef is writable internally; ensure we did not return a readonly wrapper
expect(isReadonly(active!)).toBeFalsy();
scope.stop();
});
it('reflects the active element on creation', () => {
const input = document.createElement('input');
document.body.appendChild(input);
input.focus();
const scope = effectScope();
let active: ReturnType<typeof useActiveElement>;
scope.run(() => {
active = useActiveElement();
});
// initial trigger should capture the already-focused element synchronously
expect(active!.value).toBe(input);
scope.stop();
input.remove();
});
it('traverses open shadow roots when deep', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const shadow = host.attachShadow({ mode: 'open' });
const inner = document.createElement('input');
shadow.appendChild(inner);
const scope = effectScope();
let active: ReturnType<typeof useActiveElement>;
scope.run(() => {
active = useActiveElement();
});
inner.focus();
document.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
await nextTick();
expect(active!.value).toBe(inner);
scope.stop();
host.remove();
});
it('does not descend into shadow roots when deep is false', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const shadow = host.attachShadow({ mode: 'open' });
const inner = document.createElement('input');
shadow.appendChild(inner);
const scope = effectScope();
let active: ReturnType<typeof useActiveElement>;
scope.run(() => {
active = useActiveElement({ deep: false });
});
inner.focus();
document.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
await nextTick();
// with deep:false we stay at the shadow host instead of piercing it
expect(active!.value).toBe(host);
scope.stop();
host.remove();
});
it('resets when focus leaves the window (blur with no relatedTarget)', async () => {
const input = document.createElement('input');
document.body.appendChild(input);
const scope = effectScope();
let active: ReturnType<typeof useActiveElement>;
scope.run(() => {
active = useActiveElement();
});
input.focus();
globalThis.dispatchEvent(new FocusEvent('focus'));
await nextTick();
expect(active!.value).toBe(input);
// simulate focus leaving the document entirely
input.blur();
globalThis.dispatchEvent(new FocusEvent('blur', { relatedTarget: null }));
await nextTick();
expect(active!.value).toBe(document.body);
scope.stop();
input.remove();
});
it('ignores window blur when focus moves to another element (relatedTarget set)', async () => {
const input = document.createElement('input');
document.body.appendChild(input);
const scope = effectScope();
let active: ReturnType<typeof useActiveElement>;
scope.run(() => {
active = useActiveElement();
});
input.focus();
await nextTick();
expect(active!.value).toBe(input);
// blur carrying a relatedTarget means focus stayed within the page -> ignore
const other = document.createElement('button');
globalThis.dispatchEvent(new FocusEvent('blur', { relatedTarget: other }));
await nextTick();
expect(active!.value).toBe(input);
scope.stop();
input.remove();
});
it('accepts a custom document via options', () => {
const fakeEl = document.createElement('textarea');
const fakeDocument = { activeElement: fakeEl } as unknown as Document;
const scope = effectScope();
let active: ReturnType<typeof useActiveElement>;
scope.run(() => {
active = useActiveElement({ document: fakeDocument });
});
expect(active!.value).toBe(fakeEl);
scope.stop();
});
it('does not throw and stays undefined when document has no active element', () => {
// emulate an environment (e.g. SSR / detached document) with no focus
const emptyDocument = { activeElement: null } as unknown as Document;
const scope = effectScope();
let active: ReturnType<typeof useActiveElement>;
scope.run(() => {
// pass a real window so listeners attach without error, but a doc with no focus
active = useActiveElement({ document: emptyDocument });
});
expect(active!.value).toBeNull();
scope.stop();
});
it('re-evaluates when the active element is removed (triggerOnRemoval)', async () => {
const input = document.createElement('input');
document.body.appendChild(input);
const scope = effectScope();
let active: ReturnType<typeof useActiveElement>;
scope.run(() => {
active = useActiveElement({ triggerOnRemoval: true });
});
input.focus();
await nextTick();
expect(active!.value).toBe(input);
input.remove();
// MutationObserver delivery is async; wait a microtask-ish tick
await new Promise(resolve => setTimeout(resolve, 0));
await nextTick();
expect(active!.value).toBe(document.body);
scope.stop();
});
});
@@ -0,0 +1,111 @@
import { shallowRef } from 'vue';
import type { ShallowRef } from 'vue';
import { defaultWindow } from '@/types';
import type { ConfigurableDocument, ConfigurableWindow } from '@/types';
import { useEventListener } from '@/composables/browser/useEventListener';
import { useMutationObserver } from '@/composables/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;
}
@@ -0,0 +1,43 @@
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('');
});
});
@@ -0,0 +1,73 @@
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,77 @@
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();
});
});
@@ -0,0 +1,68 @@
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 });
}
@@ -0,0 +1,169 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick } from 'vue';
import type { UseClipboardReturn } from '.';
import { useClipboard } from '.';
function stubClipboard() {
const writeText = vi.fn(async () => {});
const readText = vi.fn(async () => 'pasted');
const navigator = {
clipboard: { writeText, readText },
} as unknown as Navigator;
return { navigator, writeText, readText };
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe(useClipboard, () => {
it('reports support when the clipboard API exists', () => {
const { navigator } = stubClipboard();
const scope = effectScope();
let clip: UseClipboardReturn<false>;
scope.run(() => {
clip = useClipboard({ 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: UseClipboardReturn<false>;
scope.run(() => {
clip = useClipboard({ navigator });
});
expect(clip!.isSupported.value).toBeFalsy();
scope.stop();
});
it('copies text and sets copied flag', async () => {
const { navigator, writeText } = stubClipboard();
const scope = effectScope();
let clip: UseClipboardReturn<false>;
scope.run(() => {
clip = useClipboard({ navigator });
});
await clip!.copy('hello');
expect(writeText).toHaveBeenCalledWith('hello');
expect(clip!.text.value).toBe('hello');
expect(clip!.copied.value).toBeTruthy();
scope.stop();
});
it('copies the configured source when called without args', async () => {
const { navigator, writeText } = stubClipboard();
const scope = effectScope();
let clip: any;
scope.run(() => {
clip = useClipboard({ navigator, source: 'from-source' });
});
await clip.copy();
expect(writeText).toHaveBeenCalledWith('from-source');
scope.stop();
});
it('copies a value resolved from an async getter', async () => {
const { navigator, writeText } = stubClipboard();
const scope = effectScope();
let clip: UseClipboardReturn<false>;
scope.run(() => {
clip = useClipboard({ navigator });
});
await clip!.copy(async () => 'lazy');
expect(writeText).toHaveBeenCalledWith('lazy');
expect(clip!.text.value).toBe('lazy');
scope.stop();
});
it('skips when an async getter resolves to null', async () => {
const { navigator, writeText } = stubClipboard();
const scope = effectScope();
let clip: UseClipboardReturn<false>;
scope.run(() => {
clip = useClipboard({ navigator });
});
await clip!.copy(async () => undefined);
expect(writeText).not.toHaveBeenCalled();
expect(clip!.copied.value).toBeFalsy();
scope.stop();
});
it('exposes copyPending around an in-flight async copy', async () => {
const { navigator } = stubClipboard();
const scope = effectScope();
let clip: UseClipboardReturn<false>;
scope.run(() => {
clip = useClipboard({ navigator });
});
let release: (v: string) => void = () => {};
const promise = clip!.copy(() => new Promise<string>((resolve) => {
release = resolve;
}));
expect(clip!.copyPending.value).toBeTruthy();
release('done');
await promise;
expect(clip!.copyPending.value).toBeFalsy();
expect(clip!.text.value).toBe('done');
scope.stop();
});
it('ignores a stale async copy superseded by a newer one', async () => {
const { navigator, writeText } = stubClipboard();
const scope = effectScope();
let clip: UseClipboardReturn<false>;
scope.run(() => {
clip = useClipboard({ navigator });
});
let releaseSlow: (v: string) => void = () => {};
const slow = clip!.copy(() => new Promise<string>((resolve) => {
releaseSlow = resolve;
}));
const fast = clip!.copy(async () => 'fast');
await fast;
releaseSlow('slow');
await slow;
expect(clip!.text.value).toBe('fast');
expect(writeText).toHaveBeenCalledTimes(1);
expect(writeText).toHaveBeenCalledWith('fast');
scope.stop();
});
it('does nothing when unsupported', async () => {
const navigator = {} as unknown as Navigator;
const scope = effectScope();
let clip: UseClipboardReturn<false>;
scope.run(() => {
clip = useClipboard({ navigator });
});
await clip!.copy('x');
expect(clip!.copied.value).toBeFalsy();
scope.stop();
});
it('syncs text on copy/cut events when read is enabled', async () => {
const { navigator, readText } = stubClipboard();
const scope = effectScope();
let clip: UseClipboardReturn<false>;
scope.run(() => {
clip = useClipboard({ navigator, read: true });
});
globalThis.dispatchEvent(new Event('copy'));
await nextTick();
await Promise.resolve();
expect(readText).toHaveBeenCalled();
expect(clip!.text.value).toBe('pasted');
scope.stop();
});
});
@@ -0,0 +1,152 @@
import { shallowReadonly, shallowRef, toValue } from 'vue';
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 { useEventListener } from '@/composables/browser/useEventListener';
import { useTimeoutFn } from '@/composables/utilities/useTimeoutFn';
/**
* A value to copy: either a string or an (optionally async) getter that resolves to one.
*/
export type ClipboardValue = string | (() => Promise<string | undefined> | string | undefined);
export interface UseClipboardOptions<Source> extends ConfigurableNavigator {
/**
* Sync `text` 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;
}
export interface UseClipboardReturn<Optional extends boolean> {
/**
* Whether the async Clipboard API is available
*/
isSupported: Readonly<Ref<boolean>>;
/**
* The current clipboard text (kept in sync when `read` is enabled)
*/
text: Readonly<Ref<string>>;
/**
* `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 a value to the clipboard
*/
copy: Optional extends true ? (text?: ClipboardValue) => Promise<void> : (text: ClipboardValue) => Promise<void>;
}
/**
* @name useClipboard
* @category Browser
* @description Reactive async Clipboard API.
*
* @param {UseClipboardOptions} [options={}] Options
* @returns {UseClipboardReturn} `isSupported`, `text`, `copied`, `copyPending`, and `copy`
*
* @example
* const { text, copy, copied, isSupported } = useClipboard();
* copy('hello');
*
* @example
* // Copy a lazily/asynchronously resolved value
* copy(async () => (await fetch('/token').then(r => r.text())));
*
* @since 0.0.15
*/
export function useClipboard(options?: UseClipboardOptions<undefined>): UseClipboardReturn<false>;
export function useClipboard(options: UseClipboardOptions<MaybeRefOrGetter<string>>): UseClipboardReturn<true>;
export function useClipboard(
options: UseClipboardOptions<MaybeRefOrGetter<string> | undefined> = {},
): UseClipboardReturn<boolean> {
const {
navigator = defaultNavigator,
read = false,
source,
copiedDuring = 1500,
} = options;
const isSupported = useSupported(() => navigator && 'clipboard' in navigator);
const text = shallowRef('');
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 updateText(): Promise<void> {
text.value = await navigator!.clipboard.readText();
}
if (isSupported.value && read)
useEventListener(['copy', 'cut'], updateText, { passive: true });
async function copy(value: ClipboardValue | undefined = toValue(source)): Promise<void> {
if (!isSupported.value || value === null || value === undefined)
return;
copyPending.value = true;
try {
let resolved: string | undefined;
if (isString(value)) {
resolved = value;
}
else {
const currentId = ++lastResolveId;
resolved = await value();
// Drop a stale async resolution superseded by a newer copy
if (resolved === null || resolved === undefined || currentId !== lastResolveId)
return;
}
await navigator!.clipboard.writeText(resolved);
text.value = resolved;
copied.value = true;
timeout.start();
}
finally {
copyPending.value = false;
}
}
return {
isSupported,
text: shallowReadonly(text),
copied: shallowReadonly(copied),
copyPending: shallowReadonly(copyPending),
copy,
};
}
@@ -0,0 +1,348 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { effectScope } from 'vue';
import { useCloseWatcher } from '.';
/**
* Minimal fake of the native `CloseWatcher`: tracks instances so tests can drive
* close/destroy and assert recreation behaviour.
*/
function createCloseWatcherStub() {
const instances: FakeCloseWatcher[] = [];
class FakeCloseWatcher {
listeners = new Map<string, Set<(event: Event) => void>>();
destroyed = false;
requestCloseCalls = 0;
closeCalls = 0;
constructor() {
instances.push(this);
}
addEventListener(type: string, listener: (event: Event) => void) {
if (!this.listeners.has(type))
this.listeners.set(type, new Set());
this.listeners.get(type)!.add(listener);
}
removeEventListener(type: string, listener: (event: Event) => void) {
this.listeners.get(type)?.delete(listener);
}
requestClose() {
this.requestCloseCalls++;
this.fireClose();
}
close() {
this.closeCalls++;
this.fireClose();
}
destroy() {
this.destroyed = true;
}
private fireClose() {
const event = new Event('close');
this.listeners.get('close')?.forEach(fn => fn(event));
}
oncancel: ((event: Event) => void) | null = null;
onclose: ((event: Event) => void) | null = null;
}
// A window-like object that exposes CloseWatcher and basic event listening
const eventTarget = new EventTarget();
const win = {
CloseWatcher: FakeCloseWatcher,
addEventListener: eventTarget.addEventListener.bind(eventTarget),
removeEventListener: eventTarget.removeEventListener.bind(eventTarget),
dispatchEvent: eventTarget.dispatchEvent.bind(eventTarget),
} as unknown as Window;
return { win, instances };
}
/** A window-like object WITHOUT CloseWatcher (fallback path). */
function createFallbackWindow() {
const eventTarget = new EventTarget();
const win = {
addEventListener: eventTarget.addEventListener.bind(eventTarget),
removeEventListener: eventTarget.removeEventListener.bind(eventTarget),
dispatchEvent: eventTarget.dispatchEvent.bind(eventTarget),
} as unknown as Window;
const dispatchKey = (key: string) =>
win.dispatchEvent(new KeyboardEvent('keydown', { key }));
return { win, dispatchKey };
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe(useCloseWatcher, () => {
it('reports support when CloseWatcher exists on window', () => {
const { win } = createCloseWatcherStub();
const scope = effectScope();
let cw: ReturnType<typeof useCloseWatcher>;
scope.run(() => {
cw = useCloseWatcher({ window: win });
});
expect(cw!.isSupported.value).toBeTruthy();
scope.stop();
});
it('reports unsupported when CloseWatcher is absent', () => {
const { win } = createFallbackWindow();
const scope = effectScope();
let cw: ReturnType<typeof useCloseWatcher>;
scope.run(() => {
cw = useCloseWatcher({ window: win });
});
expect(cw!.isSupported.value).toBeFalsy();
scope.stop();
});
it('is a safe no-op when there is no window (SSR)', () => {
// Force the SSR branch with an explicit falsy (non-undefined) window so the
// default-parameter fallback to `defaultWindow` does not kick in: only
// `undefined` triggers a parameter default, `null` survives the destructure.
const scope = effectScope();
let cw: ReturnType<typeof useCloseWatcher>;
scope.run(() => {
cw = useCloseWatcher({ window: null as unknown as Window });
});
expect(cw!.isSupported.value).toBeFalsy();
const handler = vi.fn();
const stop = cw!.onClose(handler);
expect(() => cw!.close()).not.toThrow();
expect(handler).not.toHaveBeenCalled();
expect(() => stop()).not.toThrow();
expect(() => cw!.destroy()).not.toThrow();
scope.stop();
});
describe('native CloseWatcher path', () => {
it('fires registered handler when close() is requested', () => {
const { win, instances } = createCloseWatcherStub();
const scope = effectScope();
let cw: ReturnType<typeof useCloseWatcher>;
scope.run(() => {
cw = useCloseWatcher({ window: win });
});
const handler = vi.fn();
cw!.onClose(handler);
expect(instances).toHaveLength(1);
cw!.close();
expect(instances[0]!.requestCloseCalls).toBe(1);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0]![0]).toBeInstanceOf(Event);
scope.stop();
});
it('fires handler when the native close event occurs (Esc / back)', () => {
const { win, instances } = createCloseWatcherStub();
const scope = effectScope();
let cw: ReturnType<typeof useCloseWatcher>;
scope.run(() => {
cw = useCloseWatcher({ window: win });
});
const handler = vi.fn();
cw!.onClose(handler);
// Simulate the platform firing the close event (e.g. Esc / Android back)
instances[0]!.close();
expect(handler).toHaveBeenCalledTimes(1);
scope.stop();
});
it('recreates the watcher after a close so it keeps working', () => {
const { win, instances } = createCloseWatcherStub();
const scope = effectScope();
let cw: ReturnType<typeof useCloseWatcher>;
scope.run(() => {
cw = useCloseWatcher({ window: win });
});
const handler = vi.fn();
cw!.onClose(handler);
expect(instances).toHaveLength(1);
cw!.close();
// a fresh watcher is created after the close fired
expect(instances).toHaveLength(2);
cw!.close();
expect(handler).toHaveBeenCalledTimes(2);
scope.stop();
});
it('fires all registered handlers with a single watcher', () => {
const { win, instances } = createCloseWatcherStub();
const scope = effectScope();
let cw: ReturnType<typeof useCloseWatcher>;
scope.run(() => {
cw = useCloseWatcher({ window: win });
});
const a = vi.fn();
const b = vi.fn();
cw!.onClose(a);
cw!.onClose(b);
// both handlers share one native watcher
expect(instances).toHaveLength(1);
cw!.close();
expect(a).toHaveBeenCalledTimes(1);
expect(b).toHaveBeenCalledTimes(1);
scope.stop();
});
it('stop handle removes only its own handler', () => {
const { win } = createCloseWatcherStub();
const scope = effectScope();
let cw: ReturnType<typeof useCloseWatcher>;
scope.run(() => {
cw = useCloseWatcher({ window: win });
});
const a = vi.fn();
const b = vi.fn();
const stopA = cw!.onClose(a);
cw!.onClose(b);
stopA();
cw!.close();
expect(a).not.toHaveBeenCalled();
expect(b).toHaveBeenCalledTimes(1);
scope.stop();
});
it('destroy() tears down the watcher and clears handlers', () => {
const { win, instances } = createCloseWatcherStub();
const scope = effectScope();
let cw: ReturnType<typeof useCloseWatcher>;
scope.run(() => {
cw = useCloseWatcher({ window: win });
});
const handler = vi.fn();
cw!.onClose(handler);
cw!.destroy();
expect(instances[0]!.destroyed).toBeTruthy();
cw!.close();
expect(handler).not.toHaveBeenCalled();
scope.stop();
});
it('survives a handler calling destroy() during dispatch', () => {
const { win } = createCloseWatcherStub();
const scope = effectScope();
let cw: ReturnType<typeof useCloseWatcher>;
scope.run(() => {
cw = useCloseWatcher({ window: win });
});
const other = vi.fn();
cw!.onClose(() => cw!.destroy());
cw!.onClose(other);
// dispatch must not throw even though destroy() clears the set mid-loop
expect(() => cw!.close()).not.toThrow();
// the snapshot means the second handler still runs for this dispatch
expect(other).toHaveBeenCalledTimes(1);
scope.stop();
});
});
describe('fallback (keydown) path', () => {
it('fires handler on Escape keydown', () => {
const { win, dispatchKey } = createFallbackWindow();
const scope = effectScope();
let cw: ReturnType<typeof useCloseWatcher>;
scope.run(() => {
cw = useCloseWatcher({ window: win });
});
const handler = vi.fn();
cw!.onClose(handler);
dispatchKey('Escape');
expect(handler).toHaveBeenCalledTimes(1);
scope.stop();
});
it('ignores non-Escape keys', () => {
const { win, dispatchKey } = createFallbackWindow();
const scope = effectScope();
let cw: ReturnType<typeof useCloseWatcher>;
scope.run(() => {
cw = useCloseWatcher({ window: win });
});
const handler = vi.fn();
cw!.onClose(handler);
dispatchKey('Enter');
expect(handler).not.toHaveBeenCalled();
scope.stop();
});
it('close() synthesizes a close event in the fallback path', () => {
const { win } = createFallbackWindow();
const scope = effectScope();
let cw: ReturnType<typeof useCloseWatcher>;
scope.run(() => {
cw = useCloseWatcher({ window: win });
});
const handler = vi.fn();
cw!.onClose(handler);
cw!.close();
expect(handler).toHaveBeenCalledTimes(1);
scope.stop();
});
it('destroy() removes the keydown listener', () => {
const { win, dispatchKey } = createFallbackWindow();
const scope = effectScope();
let cw: ReturnType<typeof useCloseWatcher>;
scope.run(() => {
cw = useCloseWatcher({ window: win });
});
const handler = vi.fn();
cw!.onClose(handler);
cw!.destroy();
dispatchKey('Escape');
expect(handler).not.toHaveBeenCalled();
scope.stop();
});
});
it('disposes when the effect scope stops', () => {
const { win, instances } = createCloseWatcherStub();
const scope = effectScope();
let cw: ReturnType<typeof useCloseWatcher>;
scope.run(() => {
cw = useCloseWatcher({ window: win });
});
cw!.onClose(vi.fn());
expect(instances[0]!.destroyed).toBeFalsy();
scope.stop();
expect(instances[0]!.destroyed).toBeTruthy();
});
});
@@ -0,0 +1,175 @@
import type { VoidFunction } from '@robonen/stdlib';
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 { useEventListener } from '@/composables/browser/useEventListener';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
/**
* Subset of the native `CloseWatcher` instance surface we rely on.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/CloseWatcher
*/
interface CloseWatcherInstance {
requestClose: () => void;
close: () => void;
destroy: () => void;
addEventListener: (type: string, listener: (event: Event) => void) => void;
removeEventListener: (type: string, listener: (event: Event) => void) => void;
oncancel: ((event: Event) => void) | null;
onclose: ((event: Event) => void) | null;
}
type CloseWatcherConstructor = new (options?: { signal?: AbortSignal }) => CloseWatcherInstance;
/**
* Handler invoked when a close request is received.
*
* The argument is the native `close` event when the platform `CloseWatcher`
* is used, or the `Escape` `KeyboardEvent` when falling back to keydown.
*/
export type CloseWatcherHandler = (event: Event) => void;
export interface UseCloseWatcherOptions extends ConfigurableWindow {}
export interface UseCloseWatcherReturn {
/**
* Whether the native `CloseWatcher` API is available.
*/
isSupported: Readonly<Ref<boolean>>;
/**
* Register a handler for close requests (Esc key / Android back / `close()`).
*
* @returns A stop handle that removes this handler.
*/
onClose: (handler: CloseWatcherHandler) => VoidFunction;
/**
* Request a close, firing every registered handler.
*/
close: VoidFunction;
/**
* Tear down the watcher and remove all registered handlers.
*/
destroy: VoidFunction;
}
/**
* @name useCloseWatcher
* @category Browser
* @description Wrap the native `CloseWatcher` API to handle close requests
* (the `Esc` key or the Android back gesture). Falls back to listening for
* `Escape` keydown when `CloseWatcher` is unavailable. SSR-safe.
*
* @param {UseCloseWatcherOptions} [options={}] Configuration options
* @returns {UseCloseWatcherReturn} `isSupported`, `onClose`, `close`, and `destroy`
*
* @example
* const { onClose, close, isSupported } = useCloseWatcher();
* onClose(() => { dialogOpen.value = false; });
*
* @example
* // Programmatically request a close
* close();
*
* @since 0.0.15
*/
export function useCloseWatcher(options: UseCloseWatcherOptions = {}): UseCloseWatcherReturn {
const { window = defaultWindow } = options;
const isSupported = useSupported(() => !!window && 'CloseWatcher' in window);
const handlers = new Set<CloseWatcherHandler>();
let watcher: CloseWatcherInstance | undefined;
let stopFallback: VoidFunction = noop;
const dispatch = (event: Event): void => {
// Snapshot so a handler that calls destroy()/onClose() can't mutate mid-loop
// eslint-disable-next-line unicorn/no-useless-spread
for (const handler of [...handlers])
handler(event);
};
const teardownWatcher = (): void => {
watcher?.destroy();
watcher = undefined;
stopFallback();
stopFallback = noop;
};
const ensureWatcher = (): void => {
if (!window)
return;
if (isSupported.value) {
if (watcher)
return;
const CloseWatcherCtor = (window as unknown as { CloseWatcher: CloseWatcherConstructor }).CloseWatcher;
watcher = new CloseWatcherCtor();
// The native watcher deactivates after a single close; recreate it so the
// returned `close()`/Esc keep working across multiple close requests.
watcher.addEventListener('close', (event: Event) => {
watcher = undefined;
dispatch(event);
ensureWatcher();
});
return;
}
// Fallback: only one keydown listener regardless of handler count
if (stopFallback !== noop)
return;
stopFallback = useEventListener(
window,
'keydown',
(event: KeyboardEvent) => {
if (event.key === 'Escape')
dispatch(event);
},
{ passive: true },
);
};
const onClose = (handler: CloseWatcherHandler): VoidFunction => {
handlers.add(handler);
ensureWatcher();
return () => {
handlers.delete(handler);
};
};
const close = (): void => {
if (!window)
return;
if (watcher) {
watcher.requestClose();
return;
}
// No active native watcher (unsupported, torn down, or none registered yet):
// synthesize a close event so handlers still fire.
dispatch(new Event('close'));
};
const destroy = (): void => {
handlers.clear();
teardownWatcher();
};
tryOnScopeDispose(destroy);
return {
isSupported,
onClose,
close,
destroy,
};
}
@@ -0,0 +1,372 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, ref } from 'vue';
import { useColorMode } 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(useColorMode, () => {
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('applies the system class when in auto mode (dark)', async () => {
const prefersDark = makeMql(true);
const { win } = makeWindow(prefersDark);
const scope = effectScope();
let mode: ReturnType<typeof useColorMode>;
scope.run(() => {
mode = useColorMode({ window: win });
});
await nextTick();
expect(mode!.value).toBe('dark');
expect(mode!.system.value).toBe('dark');
expect(mode!.state.value).toBe('dark');
expect(document.documentElement.classList.contains('dark')).toBeTruthy();
expect(document.documentElement.classList.contains('light')).toBeFalsy();
scope.stop();
});
it('applies the system class when in auto mode (light)', async () => {
const prefersDark = makeMql(false);
const { win } = makeWindow(prefersDark);
const scope = effectScope();
let mode: ReturnType<typeof useColorMode>;
scope.run(() => {
mode = useColorMode({ window: win });
});
await nextTick();
expect(mode!.system.value).toBe('light');
expect(mode!.state.value).toBe('light');
expect(document.documentElement.classList.contains('light')).toBeTruthy();
expect(document.documentElement.classList.contains('dark')).toBeFalsy();
scope.stop();
});
it('writing the ref switches the applied class and removes the previous one', async () => {
const prefersDark = makeMql(false);
const { win } = makeWindow(prefersDark);
const scope = effectScope();
let mode: ReturnType<typeof useColorMode>;
scope.run(() => {
mode = useColorMode({ window: win });
});
await nextTick();
expect(document.documentElement.classList.contains('light')).toBeTruthy();
mode!.value = 'dark';
await nextTick();
expect(document.documentElement.classList.contains('dark')).toBeTruthy();
expect(document.documentElement.classList.contains('light')).toBeFalsy();
expect(mode!.value).toBe('dark');
expect(mode!.store.value).toBe('dark');
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 mode: ReturnType<typeof useColorMode>;
scope.run(() => {
mode = useColorMode({ window: win });
});
await nextTick();
expect(mode!.state.value).toBe('light');
prefersDark.dispatch(true);
await nextTick();
expect(mode!.system.value).toBe('dark');
expect(mode!.state.value).toBe('dark');
expect(document.documentElement.classList.contains('dark')).toBeTruthy();
scope.stop();
});
it('persists the value to storage and reads it back', async () => {
const prefersDark = makeMql(false);
const { win, storage } = makeWindow(prefersDark);
const scope = effectScope();
let mode: ReturnType<typeof useColorMode>;
scope.run(() => {
mode = useColorMode({ window: win });
});
await nextTick();
mode!.value = 'dark';
await nextTick();
expect(storage.getItem('vuetools-color-scheme')).toBe('dark');
// A second instance backed by the same storage should hydrate from it.
let restored: ReturnType<typeof useColorMode>;
scope.run(() => {
restored = useColorMode({ window: win });
});
await nextTick();
expect(restored!.value).toBe('dark');
scope.stop();
});
it('honours a custom storageKey', async () => {
const prefersDark = makeMql(false);
const { win, storage } = makeWindow(prefersDark);
const scope = effectScope();
let mode: ReturnType<typeof useColorMode>;
scope.run(() => {
mode = useColorMode({ window: win, storageKey: 'my-theme' });
});
await nextTick();
mode!.value = 'dark';
await nextTick();
expect(storage.getItem('my-theme')).toBe('dark');
expect(storage.getItem('vuetools-color-scheme')).toBeNull();
scope.stop();
});
it('does not persist when storageKey is null', async () => {
const prefersDark = makeMql(false);
const { win, map } = makeWindow(prefersDark);
const scope = effectScope();
let mode: ReturnType<typeof useColorMode>;
scope.run(() => {
mode = useColorMode({ window: win, storageKey: null });
});
await nextTick();
mode!.value = 'dark';
await nextTick();
expect(map.size).toBe(0);
expect(mode!.value).toBe('dark');
scope.stop();
});
it('emitAuto keeps the ref value as "auto" while resolving state', async () => {
const prefersDark = makeMql(true);
const { win } = makeWindow(prefersDark);
const scope = effectScope();
let mode: ReturnType<typeof useColorMode>;
scope.run(() => {
mode = useColorMode({ window: win, emitAuto: true });
});
await nextTick();
expect(mode!.value).toBe('auto');
expect(mode!.state.value).toBe('dark');
scope.stop();
});
it('writes to a custom attribute instead of class', async () => {
const prefersDark = makeMql(true);
const { win } = makeWindow(prefersDark);
const scope = effectScope();
let mode: ReturnType<typeof useColorMode>;
scope.run(() => {
mode = useColorMode({ window: win, attribute: 'data-theme' });
});
await nextTick();
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
mode!.value = 'light';
await nextTick();
expect(document.documentElement.getAttribute('data-theme')).toBe('light');
// Classes should not be touched in attribute mode.
expect(document.documentElement.classList.contains('dark')).toBeFalsy();
scope.stop();
});
it('supports custom modes', async () => {
const prefersDark = makeMql(false);
const { win } = makeWindow(prefersDark);
const scope = effectScope();
let mode: ReturnType<typeof useColorMode<'cafe' | 'dim'>>;
scope.run(() => {
mode = useColorMode<'cafe' | 'dim'>({
window: win,
modes: { cafe: 'cafe', dim: 'dim' },
initialValue: 'cafe',
});
});
await nextTick();
expect(document.documentElement.classList.contains('cafe')).toBeTruthy();
mode!.value = 'dim';
await nextTick();
expect(document.documentElement.classList.contains('dim')).toBeTruthy();
expect(document.documentElement.classList.contains('cafe')).toBeFalsy();
scope.stop();
});
it('invokes a custom onChanged handler instead of the default', async () => {
const prefersDark = makeMql(true);
const { win } = makeWindow(prefersDark);
const onChanged = vi.fn();
const scope = effectScope();
scope.run(() => {
useColorMode({ window: win, onChanged });
});
await nextTick();
expect(onChanged).toHaveBeenCalled();
const [firstMode, firstHandler] = onChanged.mock.calls[0]!;
expect(firstMode).toBe('dark');
expect(typeof firstHandler).toBe('function');
// Default handler suppressed: no class applied.
expect(document.documentElement.classList.contains('dark')).toBeFalsy();
scope.stop();
});
it('uses a custom storageRef when provided', async () => {
const prefersDark = makeMql(false);
const { win, map } = makeWindow(prefersDark);
const storageRef = ref<'auto' | 'dark' | 'light'>('dark');
const scope = effectScope();
let mode: ReturnType<typeof useColorMode>;
scope.run(() => {
mode = useColorMode({ window: win, storageRef });
});
await nextTick();
expect(mode!.value).toBe('dark');
expect(document.documentElement.classList.contains('dark')).toBeTruthy();
// storageRef bypasses useStorage, so nothing is written to localStorage.
expect(map.size).toBe(0);
storageRef.value = 'light';
await nextTick();
expect(document.documentElement.classList.contains('light')).toBeTruthy();
scope.stop();
});
it('does not throw on the SSR/unsupported path (no window)', async () => {
const scope = effectScope();
let mode: ReturnType<typeof useColorMode>;
expect(() => {
scope.run(() => {
mode = useColorMode({ window: undefined });
});
}).not.toThrow();
await nextTick();
// System detection unavailable -> defaults to light; state resolves to it.
expect(mode!.system.value).toBe('light');
expect(mode!.state.value).toBe('light');
// In-memory store still writable.
mode!.value = 'dark';
await nextTick();
expect(mode!.store.value).toBe('dark');
scope.stop();
});
});
@@ -0,0 +1,256 @@
import { computed, toRef, watch } from 'vue';
import type { ComputedRef, MaybeRefOrGetter, Ref, WritableComputedRef } from 'vue';
import { isString } from '@robonen/stdlib';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { unrefElement } from '@/composables/component/unrefElement';
import type { MaybeElementRef } from '@/composables/component/unrefElement';
import { usePreferredDark } from '@/composables/browser/usePreferredDark';
import { useStorage } from '@/composables/storage/useStorage';
import type { UseStorageOptions } from '@/composables/storage/useStorage';
import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted';
export type BasicColorMode = 'light' | 'dark';
export type BasicColorSchema = BasicColorMode | 'auto';
export interface UseColorModeOptions<T extends string = BasicColorMode> extends UseStorageOptions<T | BasicColorMode>, ConfigurableWindow {
/**
* CSS selector (or element ref) for the target element the mode is applied to.
*
* @default 'html'
*/
selector?: string | MaybeElementRef;
/**
* HTML attribute applied to the target element. Use `'class'` to toggle
* classes, or any attribute name (e.g. `'data-theme'`) to set its value.
*
* @default 'class'
*/
attribute?: string;
/**
* The initial color mode used when no value is stored.
*
* @default 'auto'
*/
initialValue?: MaybeRefOrGetter<T | BasicColorSchema>;
/**
* Map of color mode to the value applied to the target element. Extend this
* to support custom modes beyond `light`/`dark`/`auto`.
*
* @default { auto: '', light: 'light', dark: 'dark' }
*/
modes?: Partial<Record<T | BasicColorSchema, string>>;
/**
* Custom handler called whenever the resolved mode changes. Receives the
* resolved mode and the default handler, allowing you to opt out of (or
* extend) the default DOM update.
*/
onChanged?: (mode: T | BasicColorMode, defaultHandler: (mode: T | BasicColorMode) => void) => void;
/**
* Use a custom ref as the storage backing instead of `useStorage`.
*/
storageRef?: Ref<T | BasicColorSchema>;
/**
* The key persisted into storage. Pass `null` to disable persistence
* (the mode lives only in memory).
*
* @default 'vuetools-color-scheme'
*/
storageKey?: string | null;
/**
* Custom storage backend. Defaults to `window.localStorage`.
*/
storage?: Storage;
/**
* Emit `'auto'` as the writable ref value when in auto mode, instead of the
* resolved `'light'`/`'dark'`. Useful for binding a tri-state UI.
*
* @default false
*/
emitAuto?: boolean;
/**
* Briefly disable CSS transitions while the mode is applied, preventing a
* flash of transitioning colors during the switch.
*
* @default true
*/
disableTransition?: boolean;
}
export type UseColorModeReturn<T extends string = BasicColorMode>
= WritableComputedRef<T | BasicColorSchema> & {
store: Ref<T | BasicColorSchema>;
system: ComputedRef<BasicColorMode>;
state: ComputedRef<T | BasicColorMode>;
};
const CSS_DISABLE_TRANS = '*,*::before,*::after{-webkit-transition:none!important;-moz-transition:none!important;-o-transition:none!important;-ms-transition:none!important;transition:none!important}';
/**
* @name useColorMode
* @category Browser
* @description Reactive color mode (`light` / `dark` / `auto`) with system
* detection, storage persistence, and automatic application of a class or
* attribute to a target element.
*
* @param {UseColorModeOptions<T>} [options={}] Options
* @returns {UseColorModeReturn<T>} A writable ref of the mode, augmented with `{ store, system, state }`
*
* @example
* const mode = useColorMode();
* mode.value = 'dark';
*
* @example
* // Custom modes and a data attribute
* const mode = useColorMode({
* attribute: 'data-theme',
* modes: { dim: 'dim', cafe: 'cafe' },
* });
*
* @example
* // Read the resolved system + effective state
* const { system, state } = useColorMode();
*
* @since 0.0.15
*/
export function useColorMode<T extends string = BasicColorMode>(
options: UseColorModeOptions<T> = {},
): UseColorModeReturn<T> {
const {
selector = 'html',
attribute = 'class',
initialValue = 'auto',
window = defaultWindow,
storage,
storageKey = 'vuetools-color-scheme',
listenToStorageChanges = true,
storageRef,
emitAuto = false,
disableTransition = true,
} = options;
const modes = {
auto: '',
light: 'light',
dark: 'dark',
...options.modes,
} as Record<BasicColorSchema | T, string>;
const preferredDark = usePreferredDark({ window });
const system = computed<BasicColorMode>(() => preferredDark.value ? 'dark' : 'light');
const resolveStore = (): Ref<T | BasicColorSchema> => {
if (storageRef)
return storageRef;
if (storageKey === null || storageKey === undefined)
return toRef(initialValue) as Ref<T | BasicColorSchema>;
const backend = storage ?? window?.localStorage;
if (!backend)
return toRef(initialValue) as Ref<T | BasicColorSchema>;
return useStorage<T | BasicColorSchema>(storageKey, initialValue, backend, {
window,
listenToStorageChanges,
});
};
const store = resolveStore();
const state = computed<T | BasicColorMode>(() =>
store.value === 'auto'
? system.value
: store.value as T | BasicColorMode);
const updateHTMLAttrs = (target: string | MaybeElementRef, attr: string, value: string): void => {
const element = isString(target)
? window?.document?.querySelector(target)
: unrefElement(target);
if (!element)
return;
const classesToAdd = new Set<string>();
const classesToRemove = new Set<string>();
let attributeToChange: { key: string; value: string } | null = null;
if (attr === 'class') {
const next = 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))
classesToAdd.add(owned);
else
classesToRemove.add(owned);
}
}
else {
attributeToChange = { key: attr, value };
}
if (classesToAdd.size === 0 && classesToRemove.size === 0 && attributeToChange === null)
return;
let style: HTMLStyleElement | undefined;
if (disableTransition && window?.document) {
style = window.document.createElement('style');
style.append(window.document.createTextNode(CSS_DISABLE_TRANS));
window.document.head.append(style);
}
for (const className of classesToAdd)
element.classList.add(className);
for (const className of classesToRemove)
element.classList.remove(className);
if (attributeToChange)
element.setAttribute(attributeToChange.key, attributeToChange.value);
if (style && window?.document) {
// Force a reflow so the no-transition style is flushed before removal.
void window.getComputedStyle(style).opacity;
style.remove();
}
};
const defaultOnChanged = (mode: T | BasicColorMode): void => {
updateHTMLAttrs(selector, attribute, modes[mode] ?? mode);
};
const onChanged = (mode: T | BasicColorMode): void => {
if (options.onChanged)
options.onChanged(mode, defaultOnChanged);
else
defaultOnChanged(mode);
};
watch(state, onChanged, { flush: 'post', immediate: true });
tryOnMounted(() => onChanged(state.value));
const mode = computed<T | BasicColorSchema>({
get() {
return emitAuto ? store.value : state.value;
},
set(value) {
store.value = value;
},
});
return Object.assign(mode, { store, system, state }) as UseColorModeReturn<T>;
}
@@ -0,0 +1,164 @@
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();
});
});
@@ -0,0 +1,63 @@
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,143 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick } from 'vue';
import { useDocumentReadyState } from '.';
afterEach(() => {
vi.unstubAllGlobals();
Object.defineProperty(document, 'readyState', { value: 'complete', configurable: true });
});
function setReadyState(state: DocumentReadyState) {
Object.defineProperty(document, 'readyState', { value: state, configurable: true });
document.dispatchEvent(new Event('readystatechange'));
}
describe(useDocumentReadyState, () => {
it('reads the current ready state', () => {
const scope = effectScope();
let readyState: ReturnType<typeof useDocumentReadyState>;
scope.run(() => {
readyState = useDocumentReadyState();
});
expect(readyState!.value).toBe('complete');
scope.stop();
});
it('reflects a non-default initial state at setup time', () => {
Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true });
const scope = effectScope();
let readyState: ReturnType<typeof useDocumentReadyState>;
scope.run(() => {
readyState = useDocumentReadyState();
});
expect(readyState!.value).toBe('loading');
scope.stop();
});
it('updates on readystatechange', async () => {
Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true });
const scope = effectScope();
let readyState: ReturnType<typeof useDocumentReadyState>;
scope.run(() => {
readyState = useDocumentReadyState();
});
expect(readyState!.value).toBe('loading');
setReadyState('interactive');
await nextTick();
expect(readyState!.value).toBe('interactive');
setReadyState('complete');
await nextTick();
expect(readyState!.value).toBe('complete');
scope.stop();
});
it('invokes onChange with new state, previous state, and the event', async () => {
Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true });
const onChange = vi.fn();
const scope = effectScope();
scope.run(() => {
useDocumentReadyState({ onChange });
});
setReadyState('interactive');
await nextTick();
expect(onChange).toHaveBeenCalledTimes(1);
const [state, previous, event] = onChange.mock.calls[0]!;
expect(state).toBe('interactive');
expect(previous).toBe('loading');
expect(event).toBeInstanceOf(Event);
setReadyState('complete');
await nextTick();
expect(onChange).toHaveBeenCalledTimes(2);
expect(onChange.mock.calls[1]!.slice(0, 2)).toEqual(['complete', 'interactive']);
scope.stop();
});
it('does not update or fire onChange when the state is unchanged', async () => {
const onChange = vi.fn();
const scope = effectScope();
let readyState: ReturnType<typeof useDocumentReadyState>;
scope.run(() => {
readyState = useDocumentReadyState({ onChange });
});
// readyState is already 'complete'; dispatching with no real change is a no-op
document.dispatchEvent(new Event('readystatechange'));
await nextTick();
expect(onChange).not.toHaveBeenCalled();
expect(readyState!.value).toBe('complete');
scope.stop();
});
it('is SSR-safe and returns "loading" without a document', () => {
// Passing `document: undefined` resolves to the default document, so to exercise the
// no-document branch we cast a falsy value that bypasses the default-parameter logic.
const scope = effectScope();
let readyState: ReturnType<typeof useDocumentReadyState>;
scope.run(() => {
readyState = useDocumentReadyState({ document: null as unknown as Document });
});
expect(readyState!.value).toBe('loading');
scope.stop();
});
it('accepts a custom document instance', async () => {
const onChange = vi.fn();
let listener: ((event: Event) => void) | undefined;
const customDoc = {
readyState: 'loading' as DocumentReadyState,
addEventListener: (_type: string, cb: (event: Event) => void) => { listener = cb; },
removeEventListener: vi.fn(),
} as unknown as Document;
const scope = effectScope();
let readyState: ReturnType<typeof useDocumentReadyState>;
scope.run(() => {
readyState = useDocumentReadyState({ document: customDoc, onChange });
});
expect(readyState!.value).toBe('loading');
(customDoc as { readyState: DocumentReadyState }).readyState = 'complete';
listener?.(new Event('readystatechange'));
await nextTick();
expect(readyState!.value).toBe('complete');
expect(onChange).toHaveBeenCalledWith('complete', 'loading', expect.any(Event));
scope.stop();
});
});
@@ -0,0 +1,67 @@
import { shallowRef } from 'vue';
import type { ShallowRef } from 'vue';
import { defaultDocument } from '@/types';
import type { ConfigurableDocument } from '@/types';
import { useEventListener } from '@/composables/browser/useEventListener';
export interface UseDocumentReadyStateOptions extends ConfigurableDocument {
/**
* Called whenever `document.readyState` changes, receiving the new state,
* the previous state, and the originating `readystatechange` event.
*
* @default undefined
*/
onChange?: (
state: DocumentReadyState,
previous: DocumentReadyState,
event: Event,
) => void;
}
export type UseDocumentReadyStateReturn = ShallowRef<DocumentReadyState>;
/**
* @name useDocumentReadyState
* @category 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;
}
@@ -0,0 +1,131 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick } from 'vue';
import { useDocumentVisibility } from '.';
afterEach(() => {
vi.unstubAllGlobals();
Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true });
});
function setVisibility(state: DocumentVisibilityState) {
Object.defineProperty(document, 'visibilityState', { value: state, configurable: true });
document.dispatchEvent(new Event('visibilitychange'));
}
describe(useDocumentVisibility, () => {
it('reads the current visibility state', () => {
const scope = effectScope();
let visibility: ReturnType<typeof useDocumentVisibility>;
scope.run(() => {
visibility = useDocumentVisibility();
});
expect(visibility!.value).toBe('visible');
scope.stop();
});
it('updates on visibilitychange', async () => {
const scope = effectScope();
let visibility: ReturnType<typeof useDocumentVisibility>;
scope.run(() => {
visibility = useDocumentVisibility();
});
setVisibility('hidden');
await nextTick();
expect(visibility!.value).toBe('hidden');
scope.stop();
});
it('invokes onChange with new state, previous state, and the event', async () => {
const onChange = vi.fn();
const scope = effectScope();
scope.run(() => {
useDocumentVisibility({ onChange });
});
setVisibility('hidden');
await nextTick();
expect(onChange).toHaveBeenCalledTimes(1);
const [state, previous, event] = onChange.mock.calls[0]!;
expect(state).toBe('hidden');
expect(previous).toBe('visible');
expect(event).toBeInstanceOf(Event);
setVisibility('visible');
await nextTick();
expect(onChange).toHaveBeenCalledTimes(2);
expect(onChange.mock.calls[1]!.slice(0, 2)).toEqual(['visible', 'hidden']);
scope.stop();
});
it('does not update or fire onChange when the state is unchanged', async () => {
const onChange = vi.fn();
const scope = effectScope();
let visibility: ReturnType<typeof useDocumentVisibility>;
scope.run(() => {
visibility = useDocumentVisibility({ onChange });
});
// visibilityState is already 'visible'; dispatching with no real change is a no-op
document.dispatchEvent(new Event('visibilitychange'));
await nextTick();
expect(onChange).not.toHaveBeenCalled();
expect(visibility!.value).toBe('visible');
scope.stop();
});
it('reflects a non-default initial state at setup time', () => {
Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
const scope = effectScope();
let visibility: ReturnType<typeof useDocumentVisibility>;
scope.run(() => {
visibility = useDocumentVisibility();
});
expect(visibility!.value).toBe('hidden');
scope.stop();
});
it('is SSR-safe and returns "visible" without a document', () => {
const scope = effectScope();
let visibility: ReturnType<typeof useDocumentVisibility>;
scope.run(() => {
visibility = useDocumentVisibility({ document: undefined });
});
expect(visibility!.value).toBe('visible');
scope.stop();
});
it('accepts a custom document instance', async () => {
const onChange = vi.fn();
let listener: ((event: Event) => void) | undefined;
const customDoc = {
visibilityState: 'visible' as DocumentVisibilityState,
addEventListener: (_type: string, cb: (event: Event) => void) => { listener = cb; },
removeEventListener: vi.fn(),
} as unknown as Document;
const scope = effectScope();
let visibility: ReturnType<typeof useDocumentVisibility>;
scope.run(() => {
visibility = useDocumentVisibility({ document: customDoc, onChange });
});
expect(visibility!.value).toBe('visible');
(customDoc as { visibilityState: DocumentVisibilityState }).visibilityState = 'hidden';
listener?.(new Event('visibilitychange'));
await nextTick();
expect(visibility!.value).toBe('hidden');
expect(onChange).toHaveBeenCalledWith('hidden', 'visible', expect.any(Event));
scope.stop();
});
});
@@ -0,0 +1,67 @@
import { shallowRef } from 'vue';
import type { ShallowRef } from 'vue';
import { defaultDocument } from '@/types';
import type { ConfigurableDocument } from '@/types';
import { useEventListener } from '@/composables/browser/useEventListener';
export interface UseDocumentVisibilityOptions extends ConfigurableDocument {
/**
* Called whenever `document.visibilityState` changes, receiving the new state,
* the previous state, and the originating `visibilitychange` event.
*
* @default undefined
*/
onChange?: (
state: DocumentVisibilityState,
previous: DocumentVisibilityState,
event: Event,
) => void;
}
export type UseDocumentVisibilityReturn = ShallowRef<DocumentVisibilityState>;
/**
* @name useDocumentVisibility
* @category 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;
}
@@ -0,0 +1,288 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, ref } from 'vue';
import { useDropZone } from '.';
interface FakeDataTransfer {
files: File[];
items: Array<{ type: string }>;
dropEffect: string;
}
function makeFile(name = 'a.png', type = 'image/png'): File {
return new File(['x'], name, { type });
}
// jsdom lacks DragEvent / DataTransfer, so we synthesize an Event with a dataTransfer payload.
function dispatchDrag(
el: EventTarget,
type: 'dragenter' | 'dragover' | 'dragleave' | 'drop',
files: File[] = [],
): { event: Event; dataTransfer: FakeDataTransfer } {
const dataTransfer: FakeDataTransfer = {
files,
items: files.map(f => ({ type: f.type })),
dropEffect: 'none',
};
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperty(event, 'dataTransfer', { value: dataTransfer, configurable: true });
el.dispatchEvent(event);
return { event, dataTransfer };
}
describe(useDropZone, () => {
let el: HTMLElement;
beforeEach(() => {
el = document.createElement('div');
document.body.appendChild(el);
});
afterEach(() => {
el.remove();
vi.unstubAllGlobals();
});
it('exposes reactive state', () => {
const scope = effectScope();
scope.run(() => {
const { isOverDropZone, files, isSupported } = useDropZone(el);
expect(isOverDropZone.value).toBeFalsy();
expect(files.value).toBeNull();
expect(isSupported).toBeDefined();
});
scope.stop();
});
it('sets isOverDropZone on dragenter and clears on matching dragleave', () => {
const scope = effectScope();
scope.run(() => {
const { isOverDropZone } = useDropZone(el);
dispatchDrag(el, 'dragenter', [makeFile()]);
expect(isOverDropZone.value).toBeTruthy();
dispatchDrag(el, 'dragleave', [makeFile()]);
expect(isOverDropZone.value).toBeFalsy();
});
scope.stop();
});
it('uses a counter so nested enter/leave keeps isOverDropZone true', () => {
const scope = effectScope();
scope.run(() => {
const { isOverDropZone } = useDropZone(el);
dispatchDrag(el, 'dragenter', [makeFile()]);
dispatchDrag(el, 'dragenter', [makeFile()]);
expect(isOverDropZone.value).toBeTruthy();
dispatchDrag(el, 'dragleave', [makeFile()]);
expect(isOverDropZone.value).toBeTruthy();
dispatchDrag(el, 'dragleave', [makeFile()]);
expect(isOverDropZone.value).toBeFalsy();
});
scope.stop();
});
it('collects dropped files and resets isOverDropZone', () => {
const scope = effectScope();
scope.run(() => {
const { files, isOverDropZone } = useDropZone(el);
dispatchDrag(el, 'dragenter', [makeFile()]);
const dropped = [makeFile('one.png'), makeFile('two.png')];
dispatchDrag(el, 'drop', dropped);
expect(files.value).toHaveLength(2);
expect(files.value?.[0]!.name).toBe('one.png');
expect(isOverDropZone.value).toBeFalsy();
});
scope.stop();
});
it('invokes lifecycle callbacks', () => {
const scope = effectScope();
scope.run(() => {
const onEnter = vi.fn();
const onOver = vi.fn();
const onLeave = vi.fn();
const onDrop = vi.fn();
useDropZone(el, { onEnter, onOver, onLeave, onDrop });
const f = [makeFile()];
dispatchDrag(el, 'dragenter', f);
dispatchDrag(el, 'dragover', f);
dispatchDrag(el, 'dragleave', f);
dispatchDrag(el, 'drop', f);
expect(onEnter).toHaveBeenCalledTimes(1);
expect(onOver).toHaveBeenCalledTimes(1);
expect(onLeave).toHaveBeenCalledTimes(1);
expect(onDrop).toHaveBeenCalledTimes(1);
expect(onEnter).toHaveBeenCalledWith(null, expect.any(Event));
expect(onDrop.mock.calls[0]![0]).toHaveLength(1);
});
scope.stop();
});
it('accepts a shorthand onDrop function as options', () => {
const scope = effectScope();
scope.run(() => {
const onDrop = vi.fn();
useDropZone(el, onDrop);
dispatchDrag(el, 'drop', [makeFile()]);
expect(onDrop).toHaveBeenCalledTimes(1);
});
scope.stop();
});
it('respects multiple: false by keeping only the first file', () => {
const scope = effectScope();
scope.run(() => {
const { files } = useDropZone(el, { multiple: false });
// Two files dragged: validation should reject, so drop is ignored
dispatchDrag(el, 'drop', [makeFile('a.png'), makeFile('b.png')]);
expect(files.value).toBeNull();
// Single file passes and only the first is kept
dispatchDrag(el, 'drop', [makeFile('solo.png')]);
expect(files.value).toHaveLength(1);
expect(files.value?.[0]!.name).toBe('solo.png');
});
scope.stop();
});
it('filters by dataTypes array', () => {
const scope = effectScope();
scope.run(() => {
const onDrop = vi.fn();
const { files } = useDropZone(el, { dataTypes: ['image/png'], onDrop });
// wrong type rejected
dispatchDrag(el, 'drop', [makeFile('doc.pdf', 'application/pdf')]);
expect(files.value).toBeNull();
expect(onDrop).not.toHaveBeenCalled();
// correct type accepted
dispatchDrag(el, 'drop', [makeFile('img.png', 'image/png')]);
expect(files.value).toHaveLength(1);
expect(onDrop).toHaveBeenCalledTimes(1);
});
scope.stop();
});
it('supports dataTypes as a predicate function', () => {
const scope = effectScope();
scope.run(() => {
const predicate = vi.fn((types: readonly string[]) => types.includes('image/png'));
const { files } = useDropZone(el, { dataTypes: predicate });
dispatchDrag(el, 'drop', [makeFile('img.png', 'image/png')]);
expect(predicate).toHaveBeenCalled();
expect(files.value).toHaveLength(1);
});
scope.stop();
});
it('reacts to a reactive dataTypes ref', () => {
const scope = effectScope();
scope.run(() => {
const allowed = ref<string[]>(['image/png']);
const { files } = useDropZone(el, { dataTypes: allowed });
dispatchDrag(el, 'drop', [makeFile('doc.pdf', 'application/pdf')]);
expect(files.value).toBeNull();
allowed.value = ['application/pdf'];
dispatchDrag(el, 'drop', [makeFile('doc.pdf', 'application/pdf')]);
expect(files.value).toHaveLength(1);
});
scope.stop();
});
it('sets dropEffect to none for invalid drags', () => {
const scope = effectScope();
scope.run(() => {
useDropZone(el, { dataTypes: ['image/png'] });
const { dataTransfer } = dispatchDrag(el, 'dragenter', [makeFile('doc.pdf', 'application/pdf')]);
expect(dataTransfer.dropEffect).toBe('none');
});
scope.stop();
});
it('sets dropEffect to copy for valid drags', () => {
const scope = effectScope();
scope.run(() => {
useDropZone(el, { dataTypes: ['image/png'] });
const { dataTransfer } = dispatchDrag(el, 'dragenter', [makeFile('img.png', 'image/png')]);
expect(dataTransfer.dropEffect).toBe('copy');
});
scope.stop();
});
it('preventDefaultForUnhandled calls preventDefault on invalid drags', () => {
const scope = effectScope();
scope.run(() => {
useDropZone(el, { dataTypes: ['image/png'], preventDefaultForUnhandled: true });
const { event } = dispatchDrag(el, 'dragenter', [makeFile('doc.pdf', 'application/pdf')]);
expect(event.defaultPrevented).toBeTruthy();
});
scope.stop();
});
it('works with a reactive element ref target', async () => {
const scope = effectScope();
await scope.run(async () => {
const target = ref<HTMLElement | null>(null);
const { isOverDropZone } = useDropZone(target);
target.value = el;
await nextTick();
dispatchDrag(el, 'dragenter', [makeFile()]);
expect(isOverDropZone.value).toBeTruthy();
});
scope.stop();
});
it('works with document as the target', () => {
const scope = effectScope();
scope.run(() => {
const { isOverDropZone } = useDropZone(document);
dispatchDrag(document, 'dragenter', [makeFile()]);
expect(isOverDropZone.value).toBeTruthy();
});
scope.stop();
});
it('stops listening after the scope is disposed', () => {
const onDrop = vi.fn();
const scope = effectScope();
scope.run(() => {
useDropZone(el, { onDrop });
});
scope.stop();
dispatchDrag(el, 'drop', [makeFile()]);
expect(onDrop).not.toHaveBeenCalled();
});
it('reports isSupported via the configurable window option', () => {
const scope = effectScope();
scope.run(() => {
const { isSupported } = useDropZone(el, { window: undefined });
expect(isSupported.value).toBeFalsy();
});
scope.stop();
});
});
@@ -0,0 +1,205 @@
import type { ComputedRef, MaybeRef, MaybeRefOrGetter, ShallowRef } from 'vue';
import { shallowRef, toValue, unref } from 'vue';
import { isFunction } from '@robonen/stdlib';
import { useEventListener } from '@/composables/browser/useEventListener';
import { useSupported } from '@/composables/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,
};
}
@@ -0,0 +1,153 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, ref } from 'vue';
import { useElementBounding } from '.';
class StubObserver {
observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
takeRecords = vi.fn(() => []);
}
describe(useElementBounding, () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', StubObserver);
vi.stubGlobal('MutationObserver', StubObserver);
});
afterEach(() => vi.unstubAllGlobals());
it('reads the bounding rect immediately', () => {
const el = document.createElement('div');
el.getBoundingClientRect = () => ({
width: 100, height: 50, top: 10, left: 20, right: 120, bottom: 60, x: 20, y: 10,
} as DOMRect);
const scope = effectScope();
let bounds: ReturnType<typeof useElementBounding>;
scope.run(() => {
bounds = useElementBounding(ref(el));
});
expect(bounds!.width.value).toBe(100);
expect(bounds!.height.value).toBe(50);
expect(bounds!.top.value).toBe(10);
expect(bounds!.left.value).toBe(20);
expect(bounds!.x.value).toBe(20);
expect(bounds!.y.value).toBe(10);
scope.stop();
});
it('update recomputes the rect', () => {
const el = document.createElement('div');
let w = 10;
el.getBoundingClientRect = () => ({ width: w, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0 } as DOMRect);
const scope = effectScope();
let bounds: ReturnType<typeof useElementBounding>;
scope.run(() => {
bounds = useElementBounding(ref(el));
});
expect(bounds!.width.value).toBe(10);
w = 200;
bounds!.update();
expect(bounds!.width.value).toBe(200);
scope.stop();
});
it('resets to zero when target is null', () => {
const scope = effectScope();
let bounds: ReturnType<typeof useElementBounding>;
scope.run(() => {
bounds = useElementBounding(ref(null));
});
expect(bounds!.width.value).toBe(0);
expect(bounds!.height.value).toBe(0);
scope.stop();
});
// NOTE: defaultWindow is captured at import time, so vi.stubGlobal does not
// reach requestAnimationFrame. We inject a fake window via the `window` option.
it('defers measurement to the next frame with updateTiming "next-frame"', () => {
const raf = vi.fn((cb: FrameRequestCallback) => {
cb(0);
return 1;
});
const fakeWindow = { requestAnimationFrame: raf, cancelAnimationFrame: vi.fn() } as unknown as Window;
const el = document.createElement('div');
let w = 10;
el.getBoundingClientRect = () => ({ width: w, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0 } as DOMRect);
const scope = effectScope();
let bounds: ReturnType<typeof useElementBounding>;
scope.run(() => {
bounds = useElementBounding(ref(el), { updateTiming: 'next-frame', window: fakeWindow });
});
// The immediate update went through requestAnimationFrame
expect(raf).toHaveBeenCalled();
expect(bounds!.width.value).toBe(10);
w = 200;
bounds!.update();
expect(bounds!.width.value).toBe(200);
scope.stop();
});
it('coalesces multiple "next-frame" updates into a single read per frame', () => {
let scheduled: FrameRequestCallback | undefined;
const raf = vi.fn((cb: FrameRequestCallback) => {
scheduled = cb;
return 1;
});
const fakeWindow = { requestAnimationFrame: raf, cancelAnimationFrame: vi.fn() } as unknown as Window;
const el = document.createElement('div');
const getRect = vi.fn(() => ({ width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0 } as DOMRect));
el.getBoundingClientRect = getRect;
const scope = effectScope();
let bounds: ReturnType<typeof useElementBounding>;
scope.run(() => {
bounds = useElementBounding(ref(el), { updateTiming: 'next-frame', immediate: false, window: fakeWindow });
});
bounds!.update();
bounds!.update();
bounds!.update();
// Only one frame was scheduled despite three update() calls
expect(raf).toHaveBeenCalledTimes(1);
expect(getRect).not.toHaveBeenCalled();
// Flushing the frame reads the rect exactly once
scheduled!(0);
expect(getRect).toHaveBeenCalledTimes(1);
// A new update after the frame flushed schedules a fresh frame
bounds!.update();
expect(raf).toHaveBeenCalledTimes(2);
scope.stop();
});
it('cancels a pending frame on scope dispose', () => {
const raf = vi.fn(() => 42);
const caf = vi.fn();
const fakeWindow = { requestAnimationFrame: raf, cancelAnimationFrame: caf } as unknown as Window;
const el = document.createElement('div');
el.getBoundingClientRect = () => ({ width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0 } as DOMRect);
const scope = effectScope();
scope.run(() => {
useElementBounding(ref(el), { updateTiming: 'next-frame', window: fakeWindow });
});
// The immediate update scheduled a frame that never ran (raf returns id without invoking)
expect(raf).toHaveBeenCalled();
scope.stop();
expect(caf).toHaveBeenCalledWith(42);
});
});
@@ -0,0 +1,199 @@
import { shallowRef, watch } from 'vue';
import type { Ref } from 'vue';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
import { unrefElement } from '@/composables/component/unrefElement';
import { useEventListener } from '@/composables/browser/useEventListener';
import { useResizeObserver } from '@/composables/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,
};
}
@@ -0,0 +1,183 @@
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();
});
});
@@ -0,0 +1,91 @@
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;
}
@@ -0,0 +1,229 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, ref } from 'vue';
import { useElementSize } from '.';
interface StubInstance {
cb: ResizeObserverCallback;
observe: ReturnType<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn>;
unobserve: ReturnType<typeof vi.fn>;
}
let instances: StubInstance[] = [];
class StubResizeObserver {
observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
cb: ResizeObserverCallback;
constructor(cb: ResizeObserverCallback) {
this.cb = cb;
instances.push(this);
}
}
function fire(width: number, height: number, fields: Partial<ResizeObserverEntry> = {}) {
instances[0]!.cb([
{
contentBoxSize: [{ inlineSize: width, blockSize: height }],
contentRect: { width, height },
...fields,
} as unknown as ResizeObserverEntry,
], {} as ResizeObserver);
}
describe(useElementSize, () => {
beforeEach(() => {
instances = [];
vi.stubGlobal('ResizeObserver', StubResizeObserver);
});
afterEach(() => vi.unstubAllGlobals());
it('uses the initial size when the target resolves to no element', () => {
const scope = effectScope();
let size: ReturnType<typeof useElementSize>;
scope.run(() => {
size = useElementSize(ref(undefined), { width: 5, height: 7 });
});
expect(size!.width.value).toBe(5);
expect(size!.height.value).toBe(7);
scope.stop();
});
it('measures synchronously on mount via offset size', () => {
const el = document.createElement('div');
Object.defineProperty(el, 'offsetWidth', { value: 80, configurable: true });
Object.defineProperty(el, 'offsetHeight', { value: 60, configurable: true });
const scope = effectScope();
let size: ReturnType<typeof useElementSize>;
scope.run(() => {
size = useElementSize(ref(el), { width: 5, height: 7 });
});
// tryOnMounted runs synchronously outside a component, overwriting the initial size.
expect(size!.width.value).toBe(80);
expect(size!.height.value).toBe(60);
scope.stop();
});
it('reports size from contentBoxSize', () => {
const el = document.createElement('div');
const scope = effectScope();
let size: ReturnType<typeof useElementSize>;
scope.run(() => {
size = useElementSize(ref(el));
});
instances[0]!.cb([
{ contentBoxSize: [{ inlineSize: 100, blockSize: 50 }], contentRect: { width: 0, height: 0 } } as unknown as ResizeObserverEntry,
], {} as ResizeObserver);
expect(size!.width.value).toBe(100);
expect(size!.height.value).toBe(50);
scope.stop();
});
it('falls back to contentRect when box sizes are missing', () => {
const el = document.createElement('div');
const scope = effectScope();
let size: ReturnType<typeof useElementSize>;
scope.run(() => {
size = useElementSize(ref(el));
});
instances[0]!.cb([
{ contentBoxSize: undefined, contentRect: { width: 30, height: 40 } } as unknown as ResizeObserverEntry,
], {} as ResizeObserver);
expect(size!.width.value).toBe(30);
expect(size!.height.value).toBe(40);
scope.stop();
});
it('normalises a single (non-array) ResizeObserverSize object', () => {
const el = document.createElement('div');
const scope = effectScope();
let size: ReturnType<typeof useElementSize>;
scope.run(() => {
size = useElementSize(ref(el));
});
// Older Firefox reports box sizes as a single object rather than an array.
instances[0]!.cb([
{ contentBoxSize: { inlineSize: 12, blockSize: 34 }, contentRect: { width: 0, height: 0 } } as unknown as ResizeObserverEntry,
], {} as ResizeObserver);
expect(size!.width.value).toBe(12);
expect(size!.height.value).toBe(34);
scope.stop();
});
it('sums multiple box fragments in a single pass', () => {
const el = document.createElement('div');
const scope = effectScope();
let size: ReturnType<typeof useElementSize>;
scope.run(() => {
size = useElementSize(ref(el));
});
instances[0]!.cb([
{
contentBoxSize: [
{ inlineSize: 10, blockSize: 5 },
{ inlineSize: 20, blockSize: 7 },
],
contentRect: { width: 0, height: 0 },
} as unknown as ResizeObserverEntry,
], {} as ResizeObserver);
expect(size!.width.value).toBe(30);
expect(size!.height.value).toBe(12);
scope.stop();
});
it('reads borderBoxSize when box is "border-box"', () => {
const el = document.createElement('div');
const scope = effectScope();
let size: ReturnType<typeof useElementSize>;
scope.run(() => {
size = useElementSize(ref(el), { width: 0, height: 0 }, { box: 'border-box' });
});
instances[0]!.cb([
{
borderBoxSize: [{ inlineSize: 200, blockSize: 120 }],
contentBoxSize: [{ inlineSize: 1, blockSize: 1 }],
contentRect: { width: 0, height: 0 },
} as unknown as ResizeObserverEntry,
], {} as ResizeObserver);
expect(size!.width.value).toBe(200);
expect(size!.height.value).toBe(120);
scope.stop();
});
it('measures SVG elements via getBoundingClientRect', () => {
const el = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
el.getBoundingClientRect = () => ({ width: 64, height: 48 }) as DOMRect;
const scope = effectScope();
let size: ReturnType<typeof useElementSize>;
scope.run(() => {
size = useElementSize(ref(el), { width: 0, height: 0 }, { window: globalThis as unknown as Window });
});
// Even though the entry advertises a different box size, the SVG path wins.
instances[0]!.cb([
{ contentBoxSize: [{ inlineSize: 999, blockSize: 999 }], contentRect: { width: 999, height: 999 } } as unknown as ResizeObserverEntry,
], {} as ResizeObserver);
expect(size!.width.value).toBe(64);
expect(size!.height.value).toBe(48);
scope.stop();
});
it('resets to 0 when the element detaches', async () => {
const el = ref<HTMLElement | undefined>(document.createElement('div'));
const scope = effectScope();
let size: ReturnType<typeof useElementSize>;
scope.run(() => {
size = useElementSize(el, { width: 5, height: 7 });
});
fire(100, 50);
expect(size!.width.value).toBe(100);
el.value = undefined;
await nextTick();
expect(size!.width.value).toBe(0);
expect(size!.height.value).toBe(0);
scope.stop();
});
it('stop() disconnects the observer and the detach watcher', async () => {
const el = ref<HTMLElement | undefined>(document.createElement('div'));
const scope = effectScope();
let size: ReturnType<typeof useElementSize>;
scope.run(() => {
size = useElementSize(el, { width: 0, height: 0 });
});
await nextTick();
expect(instances[0]!.disconnect).not.toHaveBeenCalled();
fire(100, 50);
expect(size!.width.value).toBe(100);
size!.stop();
// The observer is torn down so it stops delivering callbacks in a real browser.
expect(instances[0]!.disconnect).toHaveBeenCalled();
// The detach watcher is also stopped: clearing the target no longer resets the size to 0.
el.value = undefined;
await nextTick();
expect(size!.width.value).toBe(100);
expect(size!.height.value).toBe(50);
scope.stop();
});
});
@@ -0,0 +1,121 @@
import { computed, shallowRef, watch } from 'vue';
import type { ShallowRef } from 'vue';
import { toArray } from '@robonen/stdlib';
import type { ConfigurableWindow } from '@/types';
import { defaultWindow } from '@/types';
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
import { unrefElement } from '@/composables/component/unrefElement';
import { useResizeObserver } from '@/composables/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 };
}
@@ -0,0 +1,145 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, isReadonly, ref } from 'vue';
import type { UseElementVisibilityReturn } from '.';
import { useElementVisibility } from '.';
let instances: StubIntersectionObserver[] = [];
let lastInit: IntersectionObserverInit | undefined;
class StubIntersectionObserver {
observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
takeRecords = vi.fn();
cb: IntersectionObserverCallback;
init?: IntersectionObserverInit;
constructor(cb: IntersectionObserverCallback, init?: IntersectionObserverInit) {
this.cb = cb;
this.init = init;
lastInit = init;
instances.push(this);
}
}
describe(useElementVisibility, () => {
beforeEach(() => {
instances = [];
lastInit = undefined;
vi.stubGlobal('IntersectionObserver', StubIntersectionObserver);
});
afterEach(() => vi.unstubAllGlobals());
it('is false initially and updates on intersection', () => {
const el = document.createElement('div');
const scope = effectScope();
let isVisible: UseElementVisibilityReturn<false>;
scope.run(() => {
isVisible = useElementVisibility(ref(el));
});
expect(isVisible!.value).toBeFalsy();
instances[0]!.cb([{ isIntersecting: true, time: 1 } as IntersectionObserverEntry], {} as IntersectionObserver);
expect(isVisible!.value).toBeTruthy();
instances[0]!.cb([{ isIntersecting: false, time: 2 } as IntersectionObserverEntry], {} as IntersectionObserver);
expect(isVisible!.value).toBeFalsy();
scope.stop();
});
it('uses the most recent entry by time', () => {
const el = document.createElement('div');
const scope = effectScope();
let isVisible: UseElementVisibilityReturn<false>;
scope.run(() => {
isVisible = useElementVisibility(ref(el));
});
instances[0]!.cb([
{ isIntersecting: false, time: 5 } as IntersectionObserverEntry,
{ isIntersecting: true, time: 10 } as IntersectionObserverEntry,
], {} as IntersectionObserver);
expect(isVisible!.value).toBeTruthy();
scope.stop();
});
it('respects initialValue', () => {
const el = document.createElement('div');
const scope = effectScope();
let isVisible: UseElementVisibilityReturn<false>;
scope.run(() => {
isVisible = useElementVisibility(ref(el), { initialValue: true });
});
expect(isVisible!.value).toBeTruthy();
scope.stop();
});
it('returns a writable shallow ref (not readonly) by default', () => {
const el = document.createElement('div');
const scope = effectScope();
let isVisible: UseElementVisibilityReturn<false>;
scope.run(() => {
isVisible = useElementVisibility(ref(el));
});
expect(isReadonly(isVisible!)).toBeFalsy();
scope.stop();
});
it('forwards rootMargin and threshold to the observer', () => {
const el = document.createElement('div');
const scope = effectScope();
scope.run(() => useElementVisibility(ref(el), { rootMargin: '10px', threshold: [0, 0.5, 1] }));
expect(lastInit?.rootMargin).toBe('10px');
expect(lastInit?.threshold).toEqual([0, 0.5, 1]);
scope.stop();
});
it('stops observing after first visibility when once is true', () => {
const el = document.createElement('div');
const scope = effectScope();
let isVisible: UseElementVisibilityReturn<false>;
scope.run(() => {
isVisible = useElementVisibility(ref(el), { once: true });
});
const observer = instances[0]!;
// Not visible yet: should not disconnect.
observer.cb([{ isIntersecting: false, time: 1 } as IntersectionObserverEntry], {} as IntersectionObserver);
expect(observer.disconnect).not.toHaveBeenCalled();
expect(isVisible!.value).toBeFalsy();
// Becomes visible: stop() should disconnect the observer.
observer.cb([{ isIntersecting: true, time: 2 } as IntersectionObserverEntry], {} as IntersectionObserver);
expect(isVisible!.value).toBeTruthy();
expect(observer.disconnect).toHaveBeenCalled();
scope.stop();
});
it('exposes observer controls when controls is true', () => {
const el = document.createElement('div');
const scope = effectScope();
let result: UseElementVisibilityReturn<true>;
scope.run(() => {
result = useElementVisibility(ref(el), { controls: true });
});
expect(result!).toHaveProperty('isVisible');
expect(result!).toHaveProperty('stop');
expect(result!).toHaveProperty('pause');
expect(result!).toHaveProperty('resume');
expect(result!).toHaveProperty('isSupported');
expect(result!).toHaveProperty('isActive');
expect(result!.isVisible.value).toBeFalsy();
instances[0]!.cb([{ isIntersecting: true, time: 1 } as IntersectionObserverEntry], {} as IntersectionObserver);
expect(result!.isVisible.value).toBeTruthy();
result!.stop();
expect(instances[0]!.disconnect).toHaveBeenCalled();
scope.stop();
});
});
@@ -0,0 +1,108 @@
import { shallowRef } from 'vue';
import type { ShallowRef } from 'vue';
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
import { useIntersectionObserver } from '@/composables/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;
}
@@ -0,0 +1,59 @@
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();
});
});
@@ -0,0 +1,60 @@
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;
}
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, afterEach } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { defineComponent, effectScope, nextTick, ref } from 'vue'; import { defineComponent, effectScope, nextTick, ref } from 'vue';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { useEventListener } from '.'; import { useEventListener } from '.';
@@ -0,0 +1,169 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick } from 'vue';
import { useEyeDropper } from '.';
/**
* Build a fake `window` carrying an `EyeDropper` constructor whose `open()`
* resolves with the supplied hex. Passed through options so it reaches the
* import-time-captured `defaultWindow` substitute (see test gotcha).
*/
function createWindowWithEyeDropper(hex = '#ff0000') {
const open = vi.fn(async (_options?: { signal?: AbortSignal }) => ({ sRGBHex: hex }));
class EyeDropper {
open = open;
get [Symbol.toStringTag]() {
return 'EyeDropper' as const;
}
}
const win = { EyeDropper } as unknown as Window & typeof globalThis;
return { window: win, open };
}
describe(useEyeDropper, () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('reports supported when EyeDropper exists on window', () => {
const scope = effectScope();
const { window } = createWindowWithEyeDropper();
let result: ReturnType<typeof useEyeDropper>;
scope.run(() => {
result = useEyeDropper({ window });
});
expect(result!.isSupported.value).toBeTruthy();
scope.stop();
});
it('reports unsupported when EyeDropper is absent', () => {
const scope = effectScope();
const win = {} as unknown as Window & typeof globalThis;
let result: ReturnType<typeof useEyeDropper>;
scope.run(() => {
result = useEyeDropper({ 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 useEyeDropper>;
scope.run(() => {
result = useEyeDropper({ window: undefined as unknown as Window });
});
expect(result!.isSupported.value).toBeFalsy();
await expect(result!.open()).resolves.toBeUndefined();
scope.stop();
});
it('defaults sRGBHex to an empty string', () => {
const scope = effectScope();
const { window } = createWindowWithEyeDropper();
let result: ReturnType<typeof useEyeDropper>;
scope.run(() => {
result = useEyeDropper({ window });
});
expect(result!.sRGBHex.value).toBe('');
scope.stop();
});
it('honors the initialValue option', () => {
const scope = effectScope();
const { window } = createWindowWithEyeDropper();
let result: ReturnType<typeof useEyeDropper>;
scope.run(() => {
result = useEyeDropper({ window, initialValue: '#123456' });
});
expect(result!.sRGBHex.value).toBe('#123456');
scope.stop();
});
it('updates sRGBHex and returns the result when open() succeeds', async () => {
const scope = effectScope();
const { window, open } = createWindowWithEyeDropper('#00ff00');
let result: ReturnType<typeof useEyeDropper>;
scope.run(() => {
result = useEyeDropper({ window });
});
const picked = await result!.open();
expect(open).toHaveBeenCalledTimes(1);
expect(picked).toEqual({ sRGBHex: '#00ff00' });
await nextTick();
expect(result!.sRGBHex.value).toBe('#00ff00');
scope.stop();
});
it('forwards open options (e.g. AbortSignal) to the native open()', async () => {
const scope = effectScope();
const { window, open } = createWindowWithEyeDropper();
let result: ReturnType<typeof useEyeDropper>;
scope.run(() => {
result = useEyeDropper({ window });
});
const controller = new AbortController();
await result!.open({ signal: controller.signal });
expect(open).toHaveBeenCalledWith({ signal: controller.signal });
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 useEyeDropper>;
scope.run(() => {
result = useEyeDropper({ window: win });
});
await expect(result!.open()).resolves.toBeUndefined();
expect(result!.sRGBHex.value).toBe('');
scope.stop();
});
it('propagates rejection when the user cancels the picker', async () => {
const scope = effectScope();
const error = new DOMException('aborted', 'AbortError');
class EyeDropper {
open = vi.fn(async () => {
throw error;
});
get [Symbol.toStringTag]() {
return 'EyeDropper' as const;
}
}
const win = { EyeDropper } as unknown as Window & typeof globalThis;
let result: ReturnType<typeof useEyeDropper>;
scope.run(() => {
result = useEyeDropper({ window: win });
});
await expect(result!.open()).rejects.toThrow(error);
expect(result!.sRGBHex.value).toBe('');
scope.stop();
});
});
@@ -0,0 +1,98 @@
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';
export interface EyeDropperOpenOptions {
/**
* An `AbortSignal` that can be used to cancel the operation.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal
*/
signal?: AbortSignal;
}
export interface EyeDropperResult {
/**
* The selected color, in sRGB hexadecimal format (e.g. `#a1b2c3`).
*/
sRGBHex: string;
}
export interface EyeDropper {
open: (options?: EyeDropperOpenOptions) => Promise<EyeDropperResult>;
[Symbol.toStringTag]: 'EyeDropper';
}
export type EyeDropperConstructor = new () => EyeDropper;
export interface UseEyeDropperOptions extends ConfigurableWindow {
/**
* Initial `sRGBHex` value before any color has been picked.
*
* @default ''
*/
initialValue?: string;
}
export interface UseEyeDropperReturn {
/**
* Whether the [EyeDropper API](https://developer.mozilla.org/en-US/docs/Web/API/EyeDropper) is supported
*/
isSupported: ComputedRef<boolean>;
/**
* The most recently picked color, in sRGB hexadecimal format
*/
sRGBHex: ShallowRef<string>;
/**
* Open the eyedropper and let the user pick a color. Resolves with the
* result, or `undefined` when the API is unsupported.
*/
open: (openOptions?: EyeDropperOpenOptions) => Promise<EyeDropperResult | undefined>;
}
/**
* @name useEyeDropper
* @category Browser
* @description Reactive wrapper around the [EyeDropper API](https://developer.mozilla.org/en-US/docs/Web/API/EyeDropper) for picking colors from the screen.
*
* @param {UseEyeDropperOptions} [options={}] Options
* @returns {UseEyeDropperReturn} `isSupported`, `sRGBHex`, and `open()`
*
* @example
* const { isSupported, sRGBHex, open } = useEyeDropper();
* if (isSupported.value)
* await open();
*
* @since 0.0.15
*/
export function useEyeDropper(options: UseEyeDropperOptions = {}): UseEyeDropperReturn {
const {
window = defaultWindow,
initialValue = '',
} = options;
const isSupported = useSupported(() => !!window && 'EyeDropper' in window);
const sRGBHex = shallowRef(initialValue);
async function open(openOptions?: EyeDropperOpenOptions): Promise<EyeDropperResult | undefined> {
if (!isSupported.value || !window)
return;
const EyeDropperCtor = (window as unknown as { EyeDropper: EyeDropperConstructor }).EyeDropper;
const eyeDropper = new EyeDropperCtor();
const result = await eyeDropper.open(openOptions);
sRGBHex.value = result.sRGBHex;
return result;
}
return {
isSupported,
sRGBHex,
open,
};
}
@@ -0,0 +1,141 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { effectScope, nextTick, ref } from 'vue';
import { useFavicon } from '.';
describe(useFavicon, () => {
beforeEach(() => {
document.head.querySelectorAll('link[rel*="icon"]').forEach(el => el.remove());
});
it('creates a link element with the icon href', () => {
const scope = effectScope();
scope.run(() => useFavicon('/icon.png'));
const link = document.head.querySelector<HTMLLinkElement>('link[rel*="icon"]');
expect(link).not.toBeNull();
expect(link!.href).toContain('/icon.png');
scope.stop();
});
it('updates the href when the ref changes', async () => {
const scope = effectScope();
let favicon: ReturnType<typeof useFavicon>;
scope.run(() => {
favicon = useFavicon('/a.png');
});
favicon!.value = '/b.png';
await nextTick();
const link = document.head.querySelector<HTMLLinkElement>('link[rel*="icon"]');
expect(link!.href).toContain('/b.png');
scope.stop();
});
it('prepends the baseUrl', () => {
const scope = effectScope();
scope.run(() => useFavicon('icon.png', { baseUrl: 'https://cdn.example.com/' }));
const link = document.head.querySelector<HTMLLinkElement>('link[rel*="icon"]');
expect(link!.href).toBe('https://cdn.example.com/icon.png');
scope.stop();
});
it('sets the MIME type from the file extension', () => {
const scope = effectScope();
scope.run(() => useFavicon('/icon.svg'));
const link = document.head.querySelector<HTMLLinkElement>('link[rel*="icon"]');
expect(link!.type).toBe('image/svg');
scope.stop();
});
it('does not set a bogus MIME type for query-string or data hrefs', () => {
const scope = effectScope();
scope.run(() => useFavicon('/icon.png?v=2'));
const link = document.head.querySelector<HTMLLinkElement>('link[rel*="icon"]');
expect(link!.href).toContain('/icon.png?v=2');
expect(link!.type).toBe('');
scope.stop();
});
it('does not set a bogus MIME type for extensionless hrefs', () => {
const scope = effectScope();
scope.run(() => useFavicon('/favicon'));
const link = document.head.querySelector<HTMLLinkElement>('link[rel*="icon"]');
expect(link!.type).toBe('');
scope.stop();
});
it('tracks a getter source reactively', async () => {
const scope = effectScope();
const dark = ref(false);
scope.run(() => useFavicon(() => (dark.value ? '/dark.png' : '/light.png')));
let link = document.head.querySelector<HTMLLinkElement>('link[rel*="icon"]');
expect(link!.href).toContain('/light.png');
dark.value = true;
await nextTick();
link = document.head.querySelector<HTMLLinkElement>('link[rel*="icon"]');
expect(link!.href).toContain('/dark.png');
scope.stop();
});
it('follows an external writable ref passed as source', async () => {
const scope = effectScope();
const source = ref('/one.png');
let favicon: ReturnType<typeof useFavicon>;
scope.run(() => {
favicon = useFavicon(source);
});
// returned ref reflects the source
expect(favicon!.value).toBe('/one.png');
source.value = '/two.png';
await nextTick();
const link = document.head.querySelector<HTMLLinkElement>('link[rel*="icon"]');
expect(link!.href).toContain('/two.png');
scope.stop();
});
it('respects a custom rel attribute', () => {
const scope = effectScope();
scope.run(() => useFavicon('/apple.png', { rel: 'apple-touch-icon' }));
const link = document.head.querySelector<HTMLLinkElement>('link[rel*="apple-touch-icon"]');
expect(link).not.toBeNull();
expect(link!.href).toContain('/apple.png');
link!.remove();
scope.stop();
});
it('reuses existing matching link elements instead of creating new ones', async () => {
const scope = effectScope();
let favicon: ReturnType<typeof useFavicon>;
scope.run(() => {
favicon = useFavicon('/first.png');
});
favicon!.value = '/second.png';
await nextTick();
const links = document.head.querySelectorAll('link[rel*="icon"]');
expect(links).toHaveLength(1);
expect((links[0] as HTMLLinkElement).href).toContain('/second.png');
scope.stop();
});
it('is SSR-safe when no document is available', () => {
const scope = effectScope();
expect(() => {
scope.run(() => useFavicon('/icon.png', { document: undefined }));
}).not.toThrow();
scope.stop();
});
});
@@ -0,0 +1,109 @@
import { toRef, watch } from 'vue';
import type { ComputedRef, MaybeRef, MaybeRefOrGetter, Ref } from 'vue';
import { isString } from '@robonen/stdlib';
import { defaultDocument } from '@/types';
import type { ConfigurableDocument } from '@/types';
export interface UseFaviconOptions extends ConfigurableDocument {
/**
* Base URL prepended to the icon href
*
* @default ''
*/
baseUrl?: string;
/**
* The `rel` attribute of the favicon link element
*
* @default 'icon'
*/
rel?: string;
}
export type UseFaviconReturn = ComputedRef<string | null | undefined> | Ref<string | null | undefined>;
// Matches a real file extension at the very end of the path portion of the href,
// e.g. `/foo.png` -> `png`, but NOT `/foo.png?v=2`, `data:...`, or extensionless hrefs.
const FILE_EXTENSION_RE = /\.([a-z0-9]+)$/i;
/**
* @name useFavicon
* @category Browser
* @description Reactive favicon.
*
* @param {MaybeRefOrGetter<string | null | undefined>} [newIcon] Initial icon href. A getter or readonly ref yields a read-only `ComputedRef`; a writable ref or plain value yields a writable `Ref`.
* @param {UseFaviconOptions} [options={}] Options
* @returns {UseFaviconReturn} A ref bound to the favicon href
*
* @example
* const favicon = useFavicon();
* favicon.value = '/new-icon.png';
*
* @example
* // Track an existing reactive source (read-only result)
* const isDark = useDark();
* const favicon = useFavicon(() => isDark.value ? '/dark.png' : '/light.png');
*
* @since 0.0.15
*/
export function useFavicon(
newIcon: MaybeRefOrGetter<string | null | undefined>,
options?: UseFaviconOptions,
): ComputedRef<string | null | undefined>;
export function useFavicon(
newIcon?: MaybeRef<string | null | undefined>,
options?: UseFaviconOptions,
): Ref<string | null | undefined>;
export function useFavicon(
newIcon: MaybeRefOrGetter<string | null | undefined> = null,
options: UseFaviconOptions = {},
): UseFaviconReturn {
const {
baseUrl = '',
rel = 'icon',
document = defaultDocument,
} = options;
const favicon = toRef(newIcon);
const selector = `link[rel*="${rel}"]`;
const applyIcon = (icon: string) => {
if (!document)
return;
const href = `${baseUrl}${icon}`;
const elements = document.head.querySelectorAll<HTMLLinkElement>(selector);
if (!elements.length) {
const link = document.createElement('link');
link.rel = rel;
link.href = href;
// Only set a MIME type when the icon actually ends in a file extension;
// otherwise we'd emit garbage like `image/png?v=2` or `image/` for
// query-string, extensionless, or data: hrefs.
const extension = FILE_EXTENSION_RE.exec(icon)?.[1];
if (extension)
link.type = `image/${extension}`;
document.head.append(link);
return;
}
for (const element of elements)
element.href = href;
};
watch(
favicon,
(icon, oldIcon) => {
if (isString(icon) && icon !== oldIcon)
applyIcon(icon);
},
{ immediate: true },
);
return favicon;
}
@@ -0,0 +1,211 @@
import { describe, expect, it, vi } from 'vitest';
import { effectScope, ref } from 'vue';
import { useFileDialog } from '.';
function makeFile(name = 'a.txt'): File {
return new File(['content'], name, { type: 'text/plain' });
}
function makeFileList(files: File[]): FileList {
const list = {
length: files.length,
item: (index: number) => files[index] ?? null,
[Symbol.iterator]: () => files[Symbol.iterator](),
} as unknown as FileList;
files.forEach((file, index) => {
(list as unknown as Record<number, File>)[index] = file;
});
return list;
}
function withScope<T>(fn: () => T): { result: T; stop: () => void } {
const scope = effectScope();
const result = scope.run(fn)!;
return { result, stop: () => scope.stop() };
}
describe(useFileDialog, () => {
it('exposes files, open, reset, onChange, onCancel', () => {
const { result, stop } = withScope(() => useFileDialog());
expect(result.files.value).toBeNull();
expect(typeof result.open).toBe('function');
expect(typeof result.reset).toBe('function');
expect(typeof result.onChange).toBe('function');
expect(typeof result.onCancel).toBe('function');
stop();
});
it('seeds files from initialFiles (array)', () => {
const file = makeFile();
const { result, stop } = withScope(() => useFileDialog({ initialFiles: [file] }));
expect(result.files.value).not.toBeNull();
expect(result.files.value!).toHaveLength(1);
expect(result.files.value![0]).toBe(file);
stop();
});
it('seeds files from initialFiles (FileList)', () => {
const list = makeFileList([makeFile('x.txt'), makeFile('y.txt')]);
const { result, stop } = withScope(() => useFileDialog({ initialFiles: list }));
expect(result.files.value!).toHaveLength(2);
stop();
});
it('clicks the input element when open() is called', () => {
const input = document.createElement('input');
const click = vi.spyOn(input, 'click').mockImplementation(() => {});
const { result, stop } = withScope(() => useFileDialog({ input }));
result.open();
expect(click).toHaveBeenCalledTimes(1);
stop();
});
it('applies options to the input element on open()', () => {
const input = document.createElement('input');
vi.spyOn(input, 'click').mockImplementation(() => {});
const { result, stop } = withScope(() => useFileDialog({
input,
accept: 'image/*',
multiple: false,
directory: true,
}));
result.open();
expect(input.accept).toBe('image/*');
expect(input.multiple).toBeFalsy();
expect(input.webkitdirectory).toBeTruthy();
stop();
});
it('merges local options on open(), overriding instance options for that call', () => {
const input = document.createElement('input');
vi.spyOn(input, 'click').mockImplementation(() => {});
const { result, stop } = withScope(() => useFileDialog({ input, accept: 'image/*' }));
result.open({ accept: '.pdf', multiple: false });
expect(input.accept).toBe('.pdf');
expect(input.multiple).toBeFalsy();
stop();
});
it('sets capture attribute only when provided', () => {
const input = document.createElement('input');
vi.spyOn(input, 'click').mockImplementation(() => {});
const { result, stop } = withScope(() => useFileDialog({ input, capture: 'user' }));
result.open();
expect(input.capture).toBe('user');
stop();
});
it('reads reactive options via getters/refs', () => {
const input = document.createElement('input');
vi.spyOn(input, 'click').mockImplementation(() => {});
const accept = ref('image/*');
const { result, stop } = withScope(() => useFileDialog({ input, accept }));
result.open();
expect(input.accept).toBe('image/*');
accept.value = 'video/*';
result.open();
expect(input.accept).toBe('video/*');
stop();
});
it('updates files and triggers onChange when the input changes', () => {
const input = document.createElement('input');
const { result, stop } = withScope(() => useFileDialog({ input }));
const onChange = vi.fn();
result.onChange(onChange);
const list = makeFileList([makeFile()]);
// jsdom does not let you assign input.files via real selection, so override the getter.
Object.defineProperty(input, 'files', { configurable: true, get: () => list });
input.dispatchEvent(new Event('change'));
expect(result.files.value).toBe(list);
expect(result.files.value!).toHaveLength(1);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(list);
stop();
});
it('triggers onCancel when the dialog is dismissed', () => {
const input = document.createElement('input');
const { result, stop } = withScope(() => useFileDialog({ input }));
const onCancel = vi.fn();
result.onCancel(onCancel);
input.dispatchEvent(new Event('cancel'));
expect(onCancel).toHaveBeenCalledTimes(1);
stop();
});
it('reset() clears files and fires onChange(null) when the input had a value', () => {
const input = document.createElement('input');
// jsdom forbids assigning a non-empty value to a file input, so stub value with a settable getter/setter.
let internalValue = 'a.txt';
Object.defineProperty(input, 'value', {
configurable: true,
get: () => internalValue,
set: (v: string) => { internalValue = v; },
});
const { result, stop } = withScope(() => useFileDialog({ input }));
const onChange = vi.fn();
result.onChange(onChange);
const list = makeFileList([makeFile()]);
Object.defineProperty(input, 'files', { configurable: true, get: () => list });
input.dispatchEvent(new Event('change'));
onChange.mockClear();
result.reset();
expect(result.files.value).toBeNull();
expect(input.value).toBe('');
expect(onChange).toHaveBeenCalledWith(null);
stop();
});
it('open({ reset: true }) clears the previous selection before opening', () => {
const input = document.createElement('input');
vi.spyOn(input, 'click').mockImplementation(() => {});
const { result, stop } = withScope(() => useFileDialog({ input }));
const list = makeFileList([makeFile()]);
Object.defineProperty(input, 'files', { configurable: true, get: () => list });
input.dispatchEvent(new Event('change'));
expect(result.files.value).not.toBeNull();
result.open({ reset: true });
expect(result.files.value).toBeNull();
stop();
});
it('onChange returns an off() that unsubscribes', () => {
const input = document.createElement('input');
const { result, stop } = withScope(() => useFileDialog({ input }));
const onChange = vi.fn();
const { off } = result.onChange(onChange);
off();
Object.defineProperty(input, 'files', { configurable: true, value: makeFileList([makeFile()]) });
input.dispatchEvent(new Event('change'));
expect(onChange).not.toHaveBeenCalled();
stop();
});
it('files ref is readonly (no input element created in SSR)', () => {
// document undefined simulates SSR; no input is created so open() is a no-op.
const { result, stop } = withScope(() => useFileDialog({ document: undefined }));
expect(result.files.value).toBeNull();
expect(() => result.open()).not.toThrow();
expect(() => result.reset()).not.toThrow();
stop();
});
});
@@ -0,0 +1,244 @@
import { computed, shallowRef, toValue, watchEffect } from 'vue';
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
import { defaultDocument } from '@/types';
import type { ConfigurableDocument } from '@/types';
import { unrefElement } from '@/composables/component/unrefElement';
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
export interface UseFileDialogOptions extends ConfigurableDocument {
/**
* Allow selecting multiple files
*
* @default true
*/
multiple?: MaybeRefOrGetter<boolean>;
/**
* Comma-separated list of accepted file types (the input's `accept` attribute)
*
* @default '*'
*/
accept?: MaybeRefOrGetter<string>;
/**
* Hint for which camera/microphone to use on mobile capture (the input's `capture` attribute)
*/
capture?: MaybeRefOrGetter<string>;
/**
* Reset the selected files each time `open()` is called
*
* @default false
*/
reset?: MaybeRefOrGetter<boolean>;
/**
* Select directories instead of files (sets `webkitdirectory`)
*
* @default false
*/
directory?: MaybeRefOrGetter<boolean>;
/**
* Initial files to seed `files` with before any dialog is opened
*/
initialFiles?: File[] | FileList;
/**
* Use a custom `<input type="file">` element instead of an internally created one
*/
input?: MaybeComputedElementRef<HTMLInputElement>;
}
/**
* Subscribe to an event; returns an unsubscribe function.
*/
export type FileDialogEventHookOn<T = void> = (callback: (param: T) => void) => { off: () => void };
export interface UseFileDialogReturn {
/**
* The currently selected files, or `null` when none are selected
*/
files: ComputedRef<FileList | null>;
/**
* Open the file dialog, optionally overriding options for this call only
*/
open: (localOptions?: Partial<UseFileDialogOptions>) => void;
/**
* Clear the current selection
*/
reset: () => void;
/**
* Register a callback fired when the selection changes
*/
onChange: FileDialogEventHookOn<FileList | null>;
/**
* Register a callback fired when the dialog is dismissed without a selection
*/
onCancel: FileDialogEventHookOn;
}
const DEFAULT_OPTIONS: UseFileDialogOptions = {
multiple: true,
accept: '*',
reset: false,
directory: false,
};
interface EventHook<T> {
on: FileDialogEventHookOn<T>;
trigger: (param: T) => void;
}
function createEventHook<T = void>(): EventHook<T> {
const callbacks = new Set<(param: T) => void>();
const on: FileDialogEventHookOn<T> = (callback) => {
callbacks.add(callback);
return {
off: () => {
callbacks.delete(callback);
},
};
};
const trigger = (param: T): void => {
callbacks.forEach(cb => cb(param));
};
return { on, trigger };
}
function toFileList(files: File[] | FileList | undefined): FileList | null {
if (!files)
return null;
if (typeof FileList !== 'undefined' && files instanceof FileList)
return files;
// Materialize a plain array into a FileList via DataTransfer when available.
if (typeof DataTransfer !== 'undefined') {
const dt = new DataTransfer();
for (const file of files)
dt.items.add(file);
return dt.files;
}
// Fallback: build a FileList-like object (environments without DataTransfer, e.g. jsdom).
const array = Array.from(files);
const list = {
length: array.length,
item: (index: number) => array[index] ?? null,
[Symbol.iterator]: () => array[Symbol.iterator](),
} as unknown as FileList;
array.forEach((file, index) => {
(list as unknown as Record<number, File>)[index] = file;
});
return list;
}
/**
* @name useFileDialog
* @category Browser
* @description Open a native file dialog programmatically and reactively track the selected files.
*
* @param {UseFileDialogOptions} [options={}] Options
* @returns {UseFileDialogReturn} `files`, `open`, `reset`, `onChange`, and `onCancel`
*
* @example
* const { files, open, onChange } = useFileDialog({ accept: 'image/*' });
* onChange((selected) => console.log(selected));
* open();
*
* @example
* // Override options for a single call
* const { open } = useFileDialog();
* open({ multiple: false, accept: '.pdf' });
*
* @since 0.0.15
*/
export function useFileDialog(options: UseFileDialogOptions = {}): UseFileDialogReturn {
const {
document = defaultDocument,
} = options;
const files = shallowRef<FileList | null>(toFileList(options.initialFiles));
const { on: onChange, trigger: changeTrigger } = createEventHook<FileList | null>();
const { on: onCancel, trigger: cancelTrigger } = createEventHook();
const inputRef = shallowRef<HTMLInputElement | undefined>();
// Eagerly resolve the input element (custom or internally created) and wire its
// handlers, re-running if a reactive `options.input` target changes.
watchEffect(() => {
const input = unrefElement(options.input)
?? (document ? document.createElement('input') : undefined);
if (input) {
input.type = 'file';
input.onchange = (event: Event) => {
const result = event.target as HTMLInputElement;
files.value = result.files;
changeTrigger(files.value);
};
input.oncancel = () => {
cancelTrigger();
};
}
inputRef.value = input;
});
const reset = (): void => {
files.value = null;
const el = inputRef.value;
if (el && el.value) {
el.value = '';
changeTrigger(null);
}
};
const applyOptions = (opts: UseFileDialogOptions): void => {
const el = inputRef.value;
if (!el)
return;
el.multiple = toValue(opts.multiple)!;
el.accept = toValue(opts.accept)!;
el.webkitdirectory = toValue(opts.directory)!;
if ('capture' in opts)
el.capture = toValue(opts.capture)!;
};
const open = (localOptions?: Partial<UseFileDialogOptions>): void => {
const el = inputRef.value;
if (!el)
return;
const mergedOptions: UseFileDialogOptions = {
...DEFAULT_OPTIONS,
...options,
...localOptions,
};
applyOptions(mergedOptions);
if (toValue(mergedOptions.reset))
reset();
el.click();
};
return {
files: computed(() => files.value),
open,
reset,
onChange,
onCancel,
};
}
@@ -0,0 +1,173 @@
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();
});
});
@@ -0,0 +1,113 @@
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,4 +1,4 @@
import { describe, it, beforeEach, afterEach, expect } from 'vitest'; import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { defineComponent, nextTick } from 'vue'; import { defineComponent, nextTick } from 'vue';
import { useFocusGuard } from '.'; import { useFocusGuard } from '.';
@@ -0,0 +1,194 @@
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();
});
});
@@ -0,0 +1,83 @@
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,4 +1,4 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope } from 'vue'; import { effectScope } from 'vue';
import { useFps } from '.'; import { useFps } from '.';
@@ -0,0 +1,311 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, shallowRef } from 'vue';
import { useFullscreen } from '.';
type Listener = (ev: Event) => void;
interface FakeEl {
requestFullscreen: ReturnType<typeof vi.fn>;
addEventListener: ReturnType<typeof vi.fn>;
removeEventListener: ReturnType<typeof vi.fn>;
}
interface FakeDoc {
documentElement: FakeEl;
exitFullscreen: ReturnType<typeof vi.fn>;
fullscreenElement: Element | null;
fullScreen: boolean;
addEventListener: (event: string, cb: Listener) => void;
removeEventListener: (event: string, cb: Listener) => void;
dispatch: (event: string) => void;
}
function createFakeElement(): FakeEl {
return {
requestFullscreen: vi.fn(async () => {}),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
}
function createFakeDocument(el?: FakeEl): FakeDoc {
const listeners = new Map<string, Set<Listener>>();
const documentElement = el ?? createFakeElement();
const doc: FakeDoc = {
documentElement,
exitFullscreen: vi.fn(async () => {}),
fullscreenElement: null,
fullScreen: false,
addEventListener(event, cb) {
if (!listeners.has(event))
listeners.set(event, new Set());
listeners.get(event)!.add(cb);
},
removeEventListener(event, cb) {
listeners.get(event)?.delete(cb);
},
dispatch(event) {
listeners.get(event)?.forEach(cb => cb(new Event(event)));
},
};
return doc;
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe(useFullscreen, () => {
it('reports support when request/exit/flag methods exist', () => {
const document = createFakeDocument();
const scope = effectScope();
let fs: ReturnType<typeof useFullscreen>;
scope.run(() => {
fs = useFullscreen(undefined, { document: document as unknown as Document });
});
expect(fs!.isSupported.value).toBeTruthy();
scope.stop();
});
it('is not supported when the Fullscreen API is absent (SSR/unsupported)', () => {
const document = {
documentElement: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
} as unknown as Document;
const scope = effectScope();
let fs: ReturnType<typeof useFullscreen>;
scope.run(() => {
fs = useFullscreen(undefined, { document });
});
expect(fs!.isSupported.value).toBeFalsy();
scope.stop();
});
it('is not supported when no document is available (SSR)', () => {
const scope = effectScope();
let fs: ReturnType<typeof useFullscreen>;
// No document and no defaultDocument in jsdom-less branch — pass an explicit undefined.
scope.run(() => {
fs = useFullscreen(undefined, { document: undefined });
});
expect(fs!.isSupported.value).toBeFalsy();
scope.stop();
});
it('starts not fullscreen', () => {
const document = createFakeDocument();
const scope = effectScope();
let fs: ReturnType<typeof useFullscreen>;
scope.run(() => {
fs = useFullscreen(undefined, { document: document as unknown as Document });
});
expect(fs!.isFullscreen.value).toBeFalsy();
scope.stop();
});
it('enter() requests fullscreen on the target element and sets the flag', async () => {
const el = createFakeElement();
const document = createFakeDocument(el);
const scope = effectScope();
let fs: ReturnType<typeof useFullscreen>;
scope.run(() => {
fs = useFullscreen(undefined, { document: document as unknown as Document });
});
await fs!.enter();
expect(el.requestFullscreen).toHaveBeenCalledTimes(1);
expect(fs!.isFullscreen.value).toBeTruthy();
scope.stop();
});
it('enter() requests fullscreen on a provided target element', async () => {
const target = createFakeElement();
const document = createFakeDocument();
const scope = effectScope();
let fs: ReturnType<typeof useFullscreen>;
scope.run(() => {
fs = useFullscreen(target as unknown as HTMLElement, { document: document as unknown as Document });
});
await fs!.enter();
expect(target.requestFullscreen).toHaveBeenCalledTimes(1);
expect(document.documentElement.requestFullscreen).not.toHaveBeenCalled();
expect(fs!.isFullscreen.value).toBeTruthy();
scope.stop();
});
it('enter() is a no-op when already fullscreen', async () => {
const el = createFakeElement();
const document = createFakeDocument(el);
const scope = effectScope();
let fs: ReturnType<typeof useFullscreen>;
scope.run(() => {
fs = useFullscreen(undefined, { document: document as unknown as Document });
});
await fs!.enter();
el.requestFullscreen.mockClear();
await fs!.enter();
expect(el.requestFullscreen).not.toHaveBeenCalled();
scope.stop();
});
it('exit() calls exitFullscreen and clears the flag', async () => {
const el = createFakeElement();
const document = createFakeDocument(el);
const scope = effectScope();
let fs: ReturnType<typeof useFullscreen>;
scope.run(() => {
fs = useFullscreen(undefined, { document: document as unknown as Document });
});
await fs!.enter();
await fs!.exit();
expect(document.exitFullscreen).toHaveBeenCalledTimes(1);
expect(fs!.isFullscreen.value).toBeFalsy();
scope.stop();
});
it('exit() is a no-op when not fullscreen', async () => {
const document = createFakeDocument();
const scope = effectScope();
let fs: ReturnType<typeof useFullscreen>;
scope.run(() => {
fs = useFullscreen(undefined, { document: document as unknown as Document });
});
await fs!.exit();
expect(document.exitFullscreen).not.toHaveBeenCalled();
scope.stop();
});
it('toggle() flips between enter and exit', async () => {
const el = createFakeElement();
const document = createFakeDocument(el);
const scope = effectScope();
let fs: ReturnType<typeof useFullscreen>;
scope.run(() => {
fs = useFullscreen(undefined, { document: document as unknown as Document });
});
await fs!.toggle();
expect(fs!.isFullscreen.value).toBeTruthy();
expect(el.requestFullscreen).toHaveBeenCalledTimes(1);
await fs!.toggle();
expect(fs!.isFullscreen.value).toBeFalsy();
expect(document.exitFullscreen).toHaveBeenCalledTimes(1);
scope.stop();
});
it('does nothing when unsupported', async () => {
const document = {
documentElement: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
} as unknown as Document;
const scope = effectScope();
let fs: ReturnType<typeof useFullscreen>;
scope.run(() => {
fs = useFullscreen(undefined, { document });
});
await fs!.enter();
expect(fs!.isFullscreen.value).toBeFalsy();
scope.stop();
});
it('syncs isFullscreen to true on fullscreenchange when our element is the fullscreen element', async () => {
const document = createFakeDocument();
const scope = effectScope();
let fs: ReturnType<typeof useFullscreen>;
scope.run(() => {
fs = useFullscreen(undefined, { document: document as unknown as Document });
});
// Simulate the browser entering fullscreen for the document element.
document.fullScreen = true;
document.fullscreenElement = document.documentElement as unknown as Element;
document.dispatch('fullscreenchange');
await nextTick();
expect(fs!.isFullscreen.value).toBeTruthy();
scope.stop();
});
it('syncs isFullscreen to false on fullscreenchange when fullscreen ends', async () => {
const el = createFakeElement();
const document = createFakeDocument(el);
const scope = effectScope();
let fs: ReturnType<typeof useFullscreen>;
scope.run(() => {
fs = useFullscreen(undefined, { document: document as unknown as Document });
});
await fs!.enter();
expect(fs!.isFullscreen.value).toBeTruthy();
// Browser exits fullscreen (e.g. user pressed Escape).
document.fullScreen = false;
document.fullscreenElement = null;
document.dispatch('fullscreenchange');
await nextTick();
expect(fs!.isFullscreen.value).toBeFalsy();
scope.stop();
});
it('resolves the target from a getter ref', async () => {
const target = createFakeElement();
const elRef = shallowRef<FakeEl | null>(null);
const document = createFakeDocument();
const scope = effectScope();
let fs: ReturnType<typeof useFullscreen>;
scope.run(() => {
fs = useFullscreen(() => elRef.value as unknown as HTMLElement, { document: document as unknown as Document });
});
elRef.value = target;
await nextTick();
await fs!.enter();
expect(target.requestFullscreen).toHaveBeenCalledTimes(1);
scope.stop();
});
it('autoExit exits fullscreen when the scope is disposed', async () => {
const el = createFakeElement();
const document = createFakeDocument(el);
const scope = effectScope();
let fs: ReturnType<typeof useFullscreen>;
scope.run(() => {
fs = useFullscreen(undefined, { document: document as unknown as Document, autoExit: true });
});
await fs!.enter();
expect(fs!.isFullscreen.value).toBeTruthy();
scope.stop();
// onScopeDispose triggers exit() (fire-and-forget); allow the microtask to flush.
await Promise.resolve();
expect(document.exitFullscreen).toHaveBeenCalledTimes(1);
});
it('does not autoExit by default', async () => {
const el = createFakeElement();
const document = createFakeDocument(el);
const scope = effectScope();
let fs: ReturnType<typeof useFullscreen>;
scope.run(() => {
fs = useFullscreen(undefined, { document: document as unknown as Document });
});
await fs!.enter();
scope.stop();
await Promise.resolve();
expect(document.exitFullscreen).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,231 @@
import { computed, shallowRef } from 'vue';
import type { ComputedRef, ShallowRef } from 'vue';
import { isFunction } from '@robonen/stdlib';
import type { ConfigurableDocument } from '@/types';
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 { tryOnMounted } from '@/composables/lifecycle/tryOnMounted';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
export interface UseFullscreenOptions extends ConfigurableDocument {
/**
* Automatically exit fullscreen when the component is unmounted
*
* @default false
*/
autoExit?: boolean;
}
export interface UseFullscreenReturn {
/**
* Whether the Fullscreen API is supported for the target element
*/
isSupported: ComputedRef<boolean>;
/**
* Whether the target element is currently in fullscreen mode
*/
isFullscreen: ShallowRef<boolean>;
/**
* Request fullscreen mode for the target element
*/
enter: () => Promise<void>;
/**
* Exit fullscreen mode
*/
exit: () => Promise<void>;
/**
* Toggle fullscreen mode for the target element
*/
toggle: () => Promise<void>;
}
// Vendor-prefixed `fullscreenchange` event names across engines.
const eventHandlers = [
'fullscreenchange',
'webkitfullscreenchange',
'webkitendfullscreen',
'mozfullscreenchange',
'MSFullscreenChange',
] as unknown as Array<'fullscreenchange'>;
const requestMethods = [
'requestFullscreen',
'webkitRequestFullscreen',
'webkitEnterFullscreen',
'webkitEnterFullScreen',
'webkitRequestFullScreen',
'mozRequestFullScreen',
'msRequestFullscreen',
] as const;
const exitMethods = [
'exitFullscreen',
'webkitExitFullscreen',
'webkitExitFullScreen',
'webkitCancelFullScreen',
'mozCancelFullScreen',
'msExitFullscreen',
] as const;
const fullscreenFlags = [
'fullScreen',
'webkitIsFullScreen',
'webkitDisplayingFullscreen',
'mozFullScreen',
'msFullscreenElement',
] as const;
const fullscreenElements = [
'fullscreenElement',
'webkitFullscreenElement',
'mozFullScreenElement',
'msFullscreenElement',
] as const;
const listenerOptions = { capture: false, passive: true } as const;
/**
* @name useFullscreen
* @category Browser
* @description Reactive Fullscreen API for an element (or the document element).
* Handles vendor-prefixed fallbacks for request/exit/state detection and syncs
* `isFullscreen` from `fullscreenchange` events. SSR-safe.
*
* @param {MaybeComputedElementRef} [target] Element to display fullscreen (ref, getter, or component instance). Defaults to `document.documentElement`
* @param {UseFullscreenOptions} [options={}] Options (`document`, `autoExit`)
* @returns {UseFullscreenReturn} `{ isSupported, isFullscreen, enter, exit, toggle }`
*
* @example
* const el = useTemplateRef('el');
* const { isFullscreen, enter, exit, toggle } = useFullscreen(el);
*
* @example
* // Fullscreen the whole page
* const { toggle } = useFullscreen();
*
* @since 0.0.15
*/
export function useFullscreen(
target?: MaybeComputedElementRef,
options: UseFullscreenOptions = {},
): UseFullscreenReturn {
const {
document = defaultDocument,
autoExit = false,
} = options;
const targetRef = computed(() => unrefElement(target) ?? document?.documentElement);
const isFullscreen = shallowRef(false);
const has = (method: string): boolean =>
Boolean((document && method in document) || (targetRef.value && method in targetRef.value));
const requestMethod = computed<typeof requestMethods[number] | undefined>(
() => requestMethods.find(has),
);
const exitMethod = computed<typeof exitMethods[number] | undefined>(
() => exitMethods.find(has),
);
const fullscreenFlag = computed<typeof fullscreenFlags[number] | undefined>(
() => fullscreenFlags.find(has),
);
const fullscreenElementMethod = fullscreenElements.find(m => document && m in document);
const isSupported = useSupported(() =>
targetRef.value
&& document
&& requestMethod.value !== undefined
&& exitMethod.value !== undefined
&& fullscreenFlag.value !== undefined);
const isCurrentElementFullScreen = (): boolean => {
if (fullscreenElementMethod)
return (document as any)?.[fullscreenElementMethod] === targetRef.value;
return false;
};
const isElementFullScreen = (): boolean => {
const flag = fullscreenFlag.value;
if (!flag)
return false;
const docFlag = document && (document as any)[flag];
if (docFlag !== null && docFlag !== undefined)
return Boolean(docFlag);
// Fallback for WebKit / iOS Safari, where the flag lives on the element itself.
const elFlag = (targetRef.value as any)?.[flag];
if (elFlag !== null && elFlag !== undefined)
return Boolean(elFlag);
return false;
};
async function exit(): Promise<void> {
if (!isSupported.value || !isFullscreen.value)
return;
const method = exitMethod.value;
if (method) {
if (typeof (document as any)?.[method] === 'function')
await (document as any)[method]();
else {
// Fallback for Safari iOS, where exit lives on the element.
const el = targetRef.value as any;
if (isFunction(el?.[method]))
await el[method]();
}
}
isFullscreen.value = false;
}
async function enter(): Promise<void> {
if (!isSupported.value || isFullscreen.value)
return;
if (isElementFullScreen())
await exit();
const el = targetRef.value as any;
const method = requestMethod.value;
if (method && isFunction(el?.[method])) {
await el[method]();
isFullscreen.value = true;
}
}
async function toggle(): Promise<void> {
await (isFullscreen.value ? exit() : enter());
}
const handlerCallback = (): void => {
const elementFullScreen = isElementFullScreen();
// Only sync to `false`, or to `true` when *our* element is the fullscreen one,
// so multiple instances on the page don't clobber each other.
if (!elementFullScreen || (elementFullScreen && isCurrentElementFullScreen()))
isFullscreen.value = elementFullScreen;
};
useEventListener(document, eventHandlers, handlerCallback, listenerOptions);
useEventListener(() => targetRef.value, eventHandlers, handlerCallback, listenerOptions);
tryOnMounted(handlerCallback, { sync: false });
if (autoExit)
tryOnScopeDispose(exit);
return {
isSupported,
isFullscreen,
enter,
exit,
toggle,
};
}
@@ -0,0 +1,266 @@
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();
});
});
@@ -0,0 +1,236 @@
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,
};
}
@@ -0,0 +1,259 @@
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();
});
});
@@ -0,0 +1,169 @@
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,206 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, ref } from 'vue';
import { useIntersectionObserver } from '.';
interface StubInstance {
cb: IntersectionObserverCallback;
options?: IntersectionObserverInit;
observe: ReturnType<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn>;
}
let instances: StubInstance[] = [];
class StubIntersectionObserver {
observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
takeRecords = vi.fn();
cb: IntersectionObserverCallback;
options?: IntersectionObserverInit;
constructor(cb: IntersectionObserverCallback, options?: IntersectionObserverInit) {
this.cb = cb;
this.options = options;
instances.push(this);
}
}
describe(useIntersectionObserver, () => {
beforeEach(() => {
instances = [];
vi.stubGlobal('IntersectionObserver', StubIntersectionObserver);
});
afterEach(() => vi.unstubAllGlobals());
it('observes the target immediately', () => {
const el = document.createElement('div');
const scope = effectScope();
scope.run(() => useIntersectionObserver(ref(el), vi.fn()));
expect(instances).toHaveLength(1);
expect(instances[0]!.observe).toHaveBeenCalledWith(el);
scope.stop();
});
it('does not observe when immediate is false', () => {
const el = document.createElement('div');
const scope = effectScope();
scope.run(() => useIntersectionObserver(ref(el), vi.fn(), { immediate: false }));
expect(instances).toHaveLength(0);
scope.stop();
});
it('pause disconnects and resume re-observes', async () => {
const el = document.createElement('div');
const scope = effectScope();
let controls: ReturnType<typeof useIntersectionObserver>;
scope.run(() => {
controls = useIntersectionObserver(ref(el), vi.fn());
});
controls!.pause();
expect(instances[0]!.disconnect).toHaveBeenCalled();
expect(controls!.isActive.value).toBeFalsy();
controls!.resume();
await nextTick();
expect(controls!.isActive.value).toBeTruthy();
expect(instances).toHaveLength(2);
scope.stop();
});
it('stop disconnects and marks inactive', () => {
const el = document.createElement('div');
const scope = effectScope();
let controls: ReturnType<typeof useIntersectionObserver>;
scope.run(() => {
controls = useIntersectionObserver(ref(el), vi.fn());
});
controls!.stop();
expect(instances[0]!.disconnect).toHaveBeenCalled();
expect(controls!.isActive.value).toBeFalsy();
scope.stop();
});
it('invokes the callback with entries', () => {
const el = document.createElement('div');
const callback = vi.fn();
const scope = effectScope();
scope.run(() => useIntersectionObserver(ref(el), callback));
const entry = { isIntersecting: true, time: 1 } as IntersectionObserverEntry;
instances[0]!.cb([entry], instances[0] as unknown as IntersectionObserver);
expect(callback).toHaveBeenCalled();
scope.stop();
});
it('observes an array of targets', () => {
const a = document.createElement('div');
const b = document.createElement('div');
const scope = effectScope();
scope.run(() => useIntersectionObserver([ref(a), ref(b)], vi.fn()));
expect(instances).toHaveLength(1);
expect(instances[0]!.observe).toHaveBeenCalledWith(a);
expect(instances[0]!.observe).toHaveBeenCalledWith(b);
scope.stop();
});
it('tracks a reactive target ref of an array', async () => {
const a = document.createElement('div');
const b = document.createElement('div');
const list = ref([a]);
const scope = effectScope();
scope.run(() => useIntersectionObserver(list, vi.fn()));
expect(instances).toHaveLength(1);
expect(instances[0]!.observe).toHaveBeenCalledTimes(1);
list.value = [a, b];
await nextTick();
// recreated with both elements
expect(instances).toHaveLength(2);
expect(instances[1]!.observe).toHaveBeenCalledWith(a);
expect(instances[1]!.observe).toHaveBeenCalledWith(b);
scope.stop();
});
it('tracks a getter target', async () => {
const a = document.createElement('div');
const enabled = ref(false);
const scope = effectScope();
scope.run(() => useIntersectionObserver(() => (enabled.value ? a : null), vi.fn()));
// null target -> no observer
expect(instances).toHaveLength(0);
enabled.value = true;
await nextTick();
expect(instances).toHaveLength(1);
expect(instances[0]!.observe).toHaveBeenCalledWith(a);
scope.stop();
});
it('passes rootMargin and threshold to the observer', () => {
const el = document.createElement('div');
const scope = effectScope();
scope.run(() => useIntersectionObserver(ref(el), vi.fn(), { rootMargin: '10px', threshold: [0, 0.5, 1] }));
expect(instances[0]!.options?.rootMargin).toBe('10px');
expect(instances[0]!.options?.threshold).toEqual([0, 0.5, 1]);
scope.stop();
});
it('reacts to a reactive rootMargin', async () => {
const el = document.createElement('div');
const rootMargin = ref('0px');
const scope = effectScope();
scope.run(() => useIntersectionObserver(ref(el), vi.fn(), { rootMargin }));
expect(instances[0]!.options?.rootMargin).toBe('0px');
rootMargin.value = '20px';
await nextTick();
expect(instances).toHaveLength(2);
expect(instances[1]!.options?.rootMargin).toBe('20px');
scope.stop();
});
it('reacts to a reactive threshold', async () => {
const el = document.createElement('div');
const threshold = ref<number | number[]>(0);
const scope = effectScope();
scope.run(() => useIntersectionObserver(ref(el), vi.fn(), { threshold }));
expect(instances[0]!.options?.threshold).toBe(0);
threshold.value = 0.75;
await nextTick();
expect(instances).toHaveLength(2);
expect(instances[1]!.options?.threshold).toBe(0.75);
scope.stop();
});
it('reports unsupported and never constructs an observer', () => {
// jsdom has no native IntersectionObserver; remove the stub so the
// feature detection `'IntersectionObserver' in window` reports false.
vi.unstubAllGlobals();
delete (globalThis as Record<string, unknown>).IntersectionObserver;
const el = document.createElement('div');
const scope = effectScope();
let controls: ReturnType<typeof useIntersectionObserver>;
scope.run(() => {
controls = useIntersectionObserver(ref(el), vi.fn());
});
expect(controls!.isSupported.value).toBeFalsy();
// stop should be a safe no-op
expect(() => controls!.stop()).not.toThrow();
scope.stop();
});
});
@@ -0,0 +1,150 @@
import { computed, readonly, ref, toValue, watch } from 'vue';
import type { MaybeRefOrGetter, Ref } from 'vue';
import { noop, toArray } from '@robonen/stdlib';
import type { ConfigurableWindow } from '@/types';
import { defaultWindow } from '@/types';
import type { MaybeComputedElementRef, MaybeElement } from '@/composables/component/unrefElement';
import { unrefElement } from '@/composables/component/unrefElement';
import { useSupported } from '@/composables/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,4 +1,4 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { defineComponent, effectScope, nextTick, ref } from 'vue'; import { defineComponent, effectScope, nextTick, ref } from 'vue';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { useIntervalFn } from '.'; import { useIntervalFn } from '.';
@@ -0,0 +1,174 @@
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();
});
});
@@ -0,0 +1,83 @@
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,305 @@
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();
});
});
@@ -0,0 +1,227 @@
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;
}
@@ -0,0 +1,251 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { defineComponent, effectScope, isReadonly, nextTick, ref } from 'vue';
import { mount } from '@vue/test-utils';
import { useMediaQuery } 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 });
},
};
}
function stubMatchMedia(initialMatches: boolean) {
const mql = makeMql(initialMatches);
vi.stubGlobal('matchMedia', vi.fn(() => mql));
return mql;
}
/** Returns a different MediaQueryList per query string, so reactive query swaps can be exercised. */
function stubMatchMediaByQuery(map: Record<string, StubMql>, fallbackMatches = false) {
const spy = vi.fn((query: string) => map[query] ?? makeMql(fallbackMatches, query));
vi.stubGlobal('matchMedia', spy);
return spy;
}
describe(useMediaQuery, () => {
beforeEach(() => {
vi.stubGlobal('matchMedia', undefined);
});
afterEach(() => vi.unstubAllGlobals());
it('reflects the initial match', async () => {
stubMatchMedia(true);
const scope = effectScope();
let matches: ReturnType<typeof useMediaQuery>;
scope.run(() => {
matches = useMediaQuery('(min-width: 100px)');
});
await nextTick();
expect(matches!.value).toBeTruthy();
scope.stop();
});
it('updates when the media query changes', async () => {
const mql = stubMatchMedia(false);
const scope = effectScope();
let matches: ReturnType<typeof useMediaQuery>;
scope.run(() => {
matches = useMediaQuery('(min-width: 100px)');
});
await nextTick();
expect(matches!.value).toBeFalsy();
mql.dispatch(true);
expect(matches!.value).toBeTruthy();
scope.stop();
});
it('returns false when matchMedia is unsupported (SSR)', async () => {
const scope = effectScope();
let matches: ReturnType<typeof useMediaQuery>;
scope.run(() => {
matches = useMediaQuery('(min-width: 100px)');
});
await nextTick();
expect(matches!.value).toBeFalsy();
scope.stop();
});
it('returns a readonly ref', async () => {
stubMatchMedia(true);
const scope = effectScope();
let matches: ReturnType<typeof useMediaQuery>;
scope.run(() => {
matches = useMediaQuery('(min-width: 100px)');
});
await nextTick();
expect(isReadonly(matches!)).toBeTruthy();
scope.stop();
});
it('reacts to a changing query ref by re-binding to the new MediaQueryList', async () => {
const small = makeMql(false, '(min-width: 100px)');
const large = makeMql(true, '(min-width: 1000px)');
stubMatchMediaByQuery({
'(min-width: 100px)': small,
'(min-width: 1000px)': large,
});
const scope = effectScope();
const query = ref('(min-width: 100px)');
let matches: ReturnType<typeof useMediaQuery>;
scope.run(() => {
matches = useMediaQuery(query);
});
await nextTick();
expect(matches!.value).toBeFalsy();
query.value = '(min-width: 1000px)';
await nextTick();
expect(matches!.value).toBeTruthy();
// The listener should follow the new MediaQueryList, not the old one.
large.dispatch(false);
expect(matches!.value).toBeFalsy();
// The old MediaQueryList should no longer affect the result.
small.dispatch(true);
expect(matches!.value).toBeFalsy();
scope.stop();
});
it('also accepts a getter for the query', async () => {
stubMatchMedia(true);
const scope = effectScope();
let matches: ReturnType<typeof useMediaQuery>;
scope.run(() => {
matches = useMediaQuery(() => '(min-width: 100px)');
});
await nextTick();
expect(matches!.value).toBeTruthy();
scope.stop();
});
describe('ssrWidth', () => {
it('resolves min-width against ssrWidth when matchMedia is unsupported', async () => {
const scope = effectScope();
let wide: ReturnType<typeof useMediaQuery>;
let narrow: ReturnType<typeof useMediaQuery>;
scope.run(() => {
wide = useMediaQuery('(min-width: 1024px)', { ssrWidth: 1280 });
narrow = useMediaQuery('(min-width: 1024px)', { ssrWidth: 800 });
});
await nextTick();
expect(wide!.value).toBeTruthy();
expect(narrow!.value).toBeFalsy();
scope.stop();
});
it('resolves max-width against ssrWidth', async () => {
const scope = effectScope();
let matches: ReturnType<typeof useMediaQuery>;
scope.run(() => {
matches = useMediaQuery('(max-width: 768px)', { ssrWidth: 500 });
});
await nextTick();
expect(matches!.value).toBeTruthy();
scope.stop();
});
it('handles a min-width/max-width range', async () => {
const scope = effectScope();
let inRange: ReturnType<typeof useMediaQuery>;
let outOfRange: ReturnType<typeof useMediaQuery>;
scope.run(() => {
inRange = useMediaQuery('(min-width: 600px) and (max-width: 1200px)', { ssrWidth: 900 });
outOfRange = useMediaQuery('(min-width: 600px) and (max-width: 1200px)', { ssrWidth: 1500 });
});
await nextTick();
expect(inRange!.value).toBeTruthy();
expect(outOfRange!.value).toBeFalsy();
scope.stop();
});
it('respects `not all` negation', async () => {
const scope = effectScope();
let matches: ReturnType<typeof useMediaQuery>;
scope.run(() => {
matches = useMediaQuery('not all and (min-width: 1024px)', { ssrWidth: 1280 });
});
await nextTick();
expect(matches!.value).toBeFalsy();
scope.stop();
});
it('OR-combines comma-separated queries', async () => {
const scope = effectScope();
let matches: ReturnType<typeof useMediaQuery>;
scope.run(() => {
matches = useMediaQuery('(min-width: 2000px), (max-width: 900px)', { ssrWidth: 800 });
});
await nextTick();
expect(matches!.value).toBeTruthy();
scope.stop();
});
it('converts em/rem units using a 16px root', async () => {
const scope = effectScope();
let matches: ReturnType<typeof useMediaQuery>;
// 48em === 768px; ssrWidth 800 >= 768 → matches
scope.run(() => {
matches = useMediaQuery('(min-width: 48em)', { ssrWidth: 800 });
});
await nextTick();
expect(matches!.value).toBeTruthy();
scope.stop();
});
it('prefers the real matchMedia over ssrWidth once mounted', async () => {
// matchMedia reports false even though ssrWidth would resolve true.
stubMatchMedia(false);
let matches: ReturnType<typeof useMediaQuery>;
const wrapper = mount(defineComponent({
setup() {
matches = useMediaQuery('(min-width: 1024px)', { ssrWidth: 1280 });
return () => null;
},
}));
await nextTick();
// After mount, isSupported becomes true and the real (false) result wins.
expect(matches!.value).toBeFalsy();
wrapper.unmount();
});
it('uses ssrWidth on the first render before mount, then re-evaluates', async () => {
// Real matchMedia is available, but the very first synchronous effect
// run (pre-mount) should still resolve via ssrWidth to avoid flicker.
stubMatchMedia(false);
let matches: ReturnType<typeof useMediaQuery>;
const scope = effectScope();
// Without a mounted component, isSupported stays false, so ssrWidth wins.
scope.run(() => {
matches = useMediaQuery('(min-width: 1024px)', { ssrWidth: 1280 });
});
await nextTick();
expect(matches!.value).toBeTruthy();
scope.stop();
});
});
});
@@ -0,0 +1,120 @@
import { computed, shallowRef, toValue, watchEffect } from 'vue';
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 { useEventListener } from '@/composables/browser/useEventListener';
export interface UseMediaQueryOptions extends ConfigurableWindow {
/**
* The viewport width (in pixels) assumed during SSR, used to resolve
* `min-width` / `max-width` queries before `window.matchMedia` is available.
*
* When provided, the composable returns a best-effort match on the server
* (and the first client render) instead of always `false`, avoiding hydration
* flicker for width-based queries. Ignored once `matchMedia` is supported.
*
* @default undefined
*/
ssrWidth?: number;
}
/**
* Convert a CSS length token (e.g. `"1024px"`, `"48em"`, `"30rem"`) to pixels.
* Falls back to treating `em`/`rem` as 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;
}
/**
* Best-effort evaluation of `min-width` / `max-width` media queries against a
* known viewport width, for SSR. Comma-separated queries are OR-combined and
* `not all` negation is respected. Returns `false` for queries we can't resolve.
*/
function matchSsrWidth(query: string, width: number): boolean {
return query.split(',').some((part) => {
const not = part.includes('not all');
const minWidth = part.match(/\(\s*min-width:\s*(-?\d+(?:\.\d*)?[a-z%]+\s*)\)/);
const maxWidth = part.match(/\(\s*max-width:\s*(-?\d+(?:\.\d*)?[a-z%]+\s*)\)/);
let result = Boolean(minWidth || maxWidth);
if (minWidth && result)
result = width >= pxValue(minWidth[1]!);
if (maxWidth && result)
result = width <= pxValue(maxWidth[1]!);
return not ? !result : result;
});
}
/**
* @name useMediaQuery
* @category Browser
* @description Reactive `window.matchMedia`. SSR-safe, reactive to the query, and
* with optional SSR width resolution for `min-width` / `max-width` queries.
*
* @param {MaybeRefOrGetter<string>} query The media query (can be reactive)
* @param {UseMediaQueryOptions} [options={}] Options (custom `window`, `ssrWidth`)
* @returns {ComputedRef<boolean>} Readonly ref of whether the query currently matches
*
* @example
* const isLarge = useMediaQuery('(min-width: 1024px)');
*
* @example
* // Resolve width queries during SSR to avoid hydration flicker
* const isWide = useMediaQuery('(min-width: 1024px)', { ssrWidth: 1280 });
*
* @since 0.0.15
*/
export function useMediaQuery(
query: MaybeRefOrGetter<string>,
options: UseMediaQueryOptions = {},
): ComputedRef<boolean> {
const { window = defaultWindow, ssrWidth } = options;
const isSupported = useSupported(() =>
window && 'matchMedia' in window && isFunction(window.matchMedia));
const ssrSupport = shallowRef(isNumber(ssrWidth));
const mediaQuery = shallowRef<MediaQueryList | undefined>();
const matches = shallowRef(false);
const handler = (event: MediaQueryListEvent) => {
matches.value = event.matches;
};
watchEffect(() => {
// Resolve width-based queries from `ssrWidth` until the real API is ready.
if (ssrSupport.value) {
ssrSupport.value = !isSupported.value;
matches.value = matchSsrWidth(toValue(query), ssrWidth!);
return;
}
if (!isSupported.value)
return;
mediaQuery.value = window!.matchMedia(toValue(query));
matches.value = mediaQuery.value.matches;
});
// Reactive target: re-binds automatically when the query (and thus the
// MediaQueryList) changes, and auto-cleans on scope dispose. Passive since
// we never call preventDefault.
useEventListener(mediaQuery, 'change', handler, { passive: true });
return computed(() => matches.value);
}
@@ -0,0 +1,287 @@
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();
});
});
@@ -0,0 +1,204 @@
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;
}
@@ -0,0 +1,252 @@
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();
});
});
@@ -0,0 +1,130 @@
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 };
}
@@ -0,0 +1,193 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, ref } from 'vue';
import { useMutationObserver } from '.';
let instances: Array<{ cb: MutationCallback; observe: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn>; takeRecords: ReturnType<typeof vi.fn> }> = [];
class StubMutationObserver {
observe = vi.fn();
disconnect = vi.fn();
takeRecords = vi.fn(() => []);
cb: MutationCallback;
constructor(cb: MutationCallback) {
this.cb = cb;
instances.push(this);
}
}
describe(useMutationObserver, () => {
beforeEach(() => {
instances = [];
vi.stubGlobal('MutationObserver', StubMutationObserver);
});
afterEach(() => vi.unstubAllGlobals());
it('observes the target with the given options', () => {
const el = document.createElement('div');
const scope = effectScope();
scope.run(() => useMutationObserver(ref(el), vi.fn(), { attributes: true }));
expect(instances).toHaveLength(1);
expect(instances[0]!.observe).toHaveBeenCalledWith(el, { attributes: true });
scope.stop();
});
it('does not leak immediate/window into observer options', () => {
const el = document.createElement('div');
const scope = effectScope();
scope.run(() => useMutationObserver(ref(el), vi.fn(), { childList: true, immediate: true }));
expect(instances[0]!.observe).toHaveBeenCalledWith(el, { childList: true });
scope.stop();
});
it('disconnects on stop', () => {
const el = document.createElement('div');
const scope = effectScope();
let stop: () => void;
scope.run(() => {
stop = useMutationObserver(ref(el), vi.fn()).stop;
});
stop!();
expect(instances[0]!.disconnect).toHaveBeenCalled();
scope.stop();
});
it('forwards records to the callback', () => {
const el = document.createElement('div');
const callback = vi.fn();
const scope = effectScope();
scope.run(() => useMutationObserver(ref(el), callback));
const records = [{ type: 'attributes' } as MutationRecord];
instances[0]!.cb(records, instances[0] as unknown as MutationObserver);
expect(callback).toHaveBeenCalledWith(records, expect.anything());
scope.stop();
});
it('observes an array of targets with a single observer', () => {
const a = document.createElement('div');
const b = document.createElement('span');
const scope = effectScope();
scope.run(() => useMutationObserver([ref(a), b], vi.fn(), { childList: true }));
expect(instances).toHaveLength(1);
expect(instances[0]!.observe).toHaveBeenCalledTimes(2);
expect(instances[0]!.observe).toHaveBeenCalledWith(a, { childList: true });
expect(instances[0]!.observe).toHaveBeenCalledWith(b, { childList: true });
scope.stop();
});
it('accepts a getter returning an array of targets', () => {
const a = document.createElement('div');
const b = document.createElement('span');
const scope = effectScope();
scope.run(() => useMutationObserver(() => [a, b], vi.fn(), { childList: true }));
expect(instances).toHaveLength(1);
expect(instances[0]!.observe).toHaveBeenCalledTimes(2);
scope.stop();
});
it('deduplicates repeated targets', () => {
const a = document.createElement('div');
const scope = effectScope();
scope.run(() => useMutationObserver([a, a, ref(a)], vi.fn(), { childList: true }));
expect(instances[0]!.observe).toHaveBeenCalledTimes(1);
scope.stop();
});
it('skips nullish targets', () => {
const scope = effectScope();
scope.run(() => useMutationObserver([ref(null), ref(undefined)], vi.fn(), { childList: true }));
expect(instances).toHaveLength(0);
scope.stop();
});
it('re-observes when a reactive target changes', async () => {
const el = document.createElement('div');
const target = ref<HTMLElement | null>(null);
const scope = effectScope();
scope.run(() => useMutationObserver(target, vi.fn(), { childList: true }));
await nextTick();
expect(instances).toHaveLength(0);
target.value = el;
await nextTick();
expect(instances).toHaveLength(1);
expect(instances[0]!.observe).toHaveBeenCalledWith(el, { childList: true });
scope.stop();
});
it('does not observe when immediate is false, then resumes', async () => {
const el = document.createElement('div');
const scope = effectScope();
let api: ReturnType<typeof useMutationObserver>;
scope.run(() => {
api = useMutationObserver(ref(el), vi.fn(), { attributes: true, immediate: false });
});
await nextTick();
expect(instances).toHaveLength(0);
expect(api!.isActive.value).toBeFalsy();
api!.resume();
await nextTick();
expect(instances).toHaveLength(1);
expect(api!.isActive.value).toBeTruthy();
scope.stop();
});
it('pause disconnects and flips isActive, resume re-observes', async () => {
const el = document.createElement('div');
const scope = effectScope();
let api: ReturnType<typeof useMutationObserver>;
scope.run(() => {
api = useMutationObserver(ref(el), vi.fn(), { attributes: true });
});
expect(instances).toHaveLength(1);
api!.pause();
expect(instances[0]!.disconnect).toHaveBeenCalled();
expect(api!.isActive.value).toBeFalsy();
api!.resume();
await nextTick();
expect(instances).toHaveLength(2);
scope.stop();
});
it('takeRecords proxies to the active observer and returns undefined when inactive', () => {
const el = document.createElement('div');
const scope = effectScope();
let api: ReturnType<typeof useMutationObserver>;
scope.run(() => {
api = useMutationObserver(ref(el), vi.fn());
});
expect(api!.takeRecords()).toEqual([]);
expect(instances[0]!.takeRecords).toHaveBeenCalled();
api!.stop();
expect(api!.takeRecords()).toBeUndefined();
scope.stop();
});
it('reports isSupported false when MutationObserver is missing', () => {
const scope = effectScope();
let api: ReturnType<typeof useMutationObserver>;
const el = document.createElement('div');
scope.run(() => {
api = useMutationObserver(ref(el), vi.fn(), { window: { foo: 1 } as unknown as Window & typeof globalThis });
});
expect(api!.isSupported.value).toBeFalsy();
expect(instances).toHaveLength(0);
scope.stop();
});
});
@@ -0,0 +1,140 @@
import { computed, readonly, ref, toValue, watch } from 'vue';
import type { MaybeRefOrGetter, Ref } from 'vue';
import { toArray } from '@robonen/stdlib';
import type { ConfigurableWindow } from '@/types';
import { defaultWindow } from '@/types';
import type { MaybeComputedElementRef, MaybeElement } from '@/composables/component/unrefElement';
import { unrefElement } from '@/composables/component/unrefElement';
import { useSupported } from '@/composables/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,
};
}
@@ -0,0 +1,164 @@
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();
});
});
@@ -0,0 +1,157 @@
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),
};
}
@@ -0,0 +1,169 @@
import { describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, shallowRef } from 'vue';
import { useObjectUrl } from '.';
function createUrlStub() {
let counter = 0;
const createObjectURL = vi.fn((_object: Blob | MediaSource) => `blob:mock/${counter++}`);
const revokeObjectURL = vi.fn((_url: string) => {});
const window = { URL: { createObjectURL, revokeObjectURL } } as unknown as Window;
return { window, createObjectURL, revokeObjectURL };
}
function makeBlob(content = 'hello') {
return new Blob([content], { type: 'text/plain' });
}
describe(useObjectUrl, () => {
it('creates an object URL for an initial value', () => {
const { window, createObjectURL } = createUrlStub();
const blob = makeBlob();
const scope = effectScope();
let url: ReturnType<typeof useObjectUrl>;
scope.run(() => {
url = useObjectUrl(shallowRef(blob), { window });
});
expect(createObjectURL).toHaveBeenCalledTimes(1);
expect(createObjectURL).toHaveBeenCalledWith(blob);
expect(url!.value).toBe('blob:mock/0');
scope.stop();
});
it('returns undefined when the source is null/undefined', () => {
const { window, createObjectURL } = createUrlStub();
const scope = effectScope();
let url: ReturnType<typeof useObjectUrl>;
scope.run(() => {
url = useObjectUrl(shallowRef<Blob | undefined>(undefined), { window });
});
expect(createObjectURL).not.toHaveBeenCalled();
expect(url!.value).toBeUndefined();
scope.stop();
});
it('revokes the previous URL and creates a new one when the source changes', async () => {
const { window, createObjectURL, revokeObjectURL } = createUrlStub();
const source = shallowRef<Blob | undefined>(makeBlob('a'));
const scope = effectScope();
let url: ReturnType<typeof useObjectUrl>;
scope.run(() => {
url = useObjectUrl(source, { window });
});
expect(url!.value).toBe('blob:mock/0');
source.value = makeBlob('b');
await nextTick();
expect(revokeObjectURL).toHaveBeenCalledTimes(1);
expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock/0');
expect(createObjectURL).toHaveBeenCalledTimes(2);
expect(url!.value).toBe('blob:mock/1');
scope.stop();
});
it('revokes and clears when the source becomes null', async () => {
const { window, revokeObjectURL } = createUrlStub();
const source = shallowRef<Blob | null>(makeBlob());
const scope = effectScope();
let url: ReturnType<typeof useObjectUrl>;
scope.run(() => {
url = useObjectUrl(source, { window });
});
expect(url!.value).toBe('blob:mock/0');
source.value = null;
await nextTick();
expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock/0');
expect(url!.value).toBeUndefined();
scope.stop();
});
it('revokes the active URL on scope dispose', () => {
const { window, revokeObjectURL } = createUrlStub();
const scope = effectScope();
let url: ReturnType<typeof useObjectUrl>;
scope.run(() => {
url = useObjectUrl(shallowRef(makeBlob()), { window });
});
expect(url!.value).toBe('blob:mock/0');
expect(revokeObjectURL).not.toHaveBeenCalled();
scope.stop();
expect(revokeObjectURL).toHaveBeenCalledTimes(1);
expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock/0');
});
it('accepts a getter source', async () => {
const { window, createObjectURL } = createUrlStub();
const source = shallowRef<Blob | undefined>(undefined);
const scope = effectScope();
let url: ReturnType<typeof useObjectUrl>;
scope.run(() => {
url = useObjectUrl(() => source.value, { window });
});
expect(url!.value).toBeUndefined();
source.value = makeBlob();
await nextTick();
expect(createObjectURL).toHaveBeenCalledTimes(1);
expect(url!.value).toBe('blob:mock/0');
scope.stop();
});
it('returns a read-only ref', () => {
const { window } = createUrlStub();
const scope = effectScope();
let url: ReturnType<typeof useObjectUrl>;
scope.run(() => {
url = useObjectUrl(shallowRef(makeBlob()), { window });
});
// shallowReadonly should warn and not mutate when written to
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
// @ts-expect-error read-only ref
url!.value = 'mutated';
expect(url!.value).toBe('blob:mock/0');
warn.mockRestore();
scope.stop();
});
it('does nothing in an unsupported / SSR environment (no window)', () => {
const scope = effectScope();
// Simulate SSR: no `window` available. Destructuring defaults only kick in
// for `undefined`, so pass an explicit `null` to mirror `defaultWindow`
// being `undefined` on the server (the guard treats any falsy window the same).
let url: ReturnType<typeof useObjectUrl>;
scope.run(() => {
url = useObjectUrl(shallowRef(makeBlob()), { window: null as unknown as Window });
});
expect(url!.value).toBeUndefined();
// disposing must not throw even though there is no window
expect(() => scope.stop()).not.toThrow();
});
});
@@ -0,0 +1,55 @@
import { shallowReadonly, shallowRef, toValue, watch } from 'vue';
import type { MaybeRefOrGetter, ShallowRef } from 'vue';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
export interface UseObjectUrlOptions extends ConfigurableWindow {}
export type UseObjectUrlReturn = Readonly<ShallowRef<string | undefined>>;
/**
* @name useObjectUrl
* @category Browser
* @description Create and auto-revoke an object URL for a `Blob`, `File`, or `MediaSource`. The previous URL is revoked whenever the source changes, and the active URL is revoked on scope dispose.
*
* @param {MaybeRefOrGetter<Blob | MediaSource | null | undefined>} object The reactive source to create an object URL for
* @param {UseObjectUrlOptions} [options={}] Options
* @returns {UseObjectUrlReturn} A read-only ref holding the current object URL, or `undefined` when there is no source (or in unsupported/SSR environments)
*
* @example
* const file = shallowRef<File>();
* const url = useObjectUrl(file);
*
* @since 0.0.15
*/
export function useObjectUrl(
object: MaybeRefOrGetter<Blob | MediaSource | null | undefined>,
options: UseObjectUrlOptions = {},
): UseObjectUrlReturn {
const { window = defaultWindow } = options;
const url = shallowRef<string | undefined>();
const release = (): void => {
if (url.value)
(window as (Window & typeof globalThis) | undefined)?.URL.revokeObjectURL(url.value);
url.value = undefined;
};
watch(
() => toValue(object),
(newObject) => {
release();
if (newObject && window)
url.value = (window as Window & typeof globalThis).URL.createObjectURL(newObject);
},
{ immediate: true },
);
tryOnScopeDispose(release);
return shallowReadonly(url);
}
@@ -0,0 +1,44 @@
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();
});
});
@@ -0,0 +1,38 @@
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);
}
@@ -0,0 +1,95 @@
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();
});
});
@@ -0,0 +1,71 @@
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;
}
@@ -0,0 +1,175 @@
import { describe, expect, it, vi } from 'vitest';
import { effectScope } from 'vue';
import type { UsePermissionReturn } from '.';
import { usePermission } from '.';
function stubPermissions(state: PermissionState) {
let changeHandler: ((this: PermissionStatus, ev: Event) => any) | undefined;
const status = {
state,
addEventListener: vi.fn((_: string, handler: any) => { changeHandler = handler; }),
removeEventListener: vi.fn(() => { changeHandler = undefined; }),
};
const query = vi.fn(async () => status);
const navigator = { permissions: { query } } as unknown as Navigator;
const emitChange = (next: PermissionState) => {
status.state = next;
changeHandler?.call(status as unknown as PermissionStatus, new Event('change'));
};
return { navigator, status, query, emitChange, getChangeHandler: () => changeHandler };
}
describe(usePermission, () => {
it('resolves the permission state', async () => {
const { navigator } = stubPermissions('granted');
const scope = effectScope();
let state: UsePermissionReturn;
scope.run(() => {
state = usePermission('geolocation', { navigator });
});
await vi.waitFor(() => expect(state!.value).toBe('granted'));
scope.stop();
});
it('exposes controls when controls: true', async () => {
const { navigator, query } = stubPermissions('prompt');
const scope = effectScope();
let result: any;
scope.run(() => {
result = usePermission('camera', { controls: true, navigator });
});
expect(result.isSupported.value).toBeTruthy();
await result.query();
expect(query).toHaveBeenCalled();
scope.stop();
});
it('returns undefined state when unsupported', () => {
const navigator = {} as Navigator;
const scope = effectScope();
let state: UsePermissionReturn;
scope.run(() => {
state = usePermission('geolocation', { navigator });
});
expect(state!.value).toBeUndefined();
scope.stop();
});
it('reacts to the status change event', async () => {
const { navigator, emitChange } = stubPermissions('prompt');
const scope = effectScope();
let state: UsePermissionReturn;
scope.run(() => {
state = usePermission('notifications', { navigator });
});
await vi.waitFor(() => expect(state!.value).toBe('prompt'));
emitChange('granted');
await vi.waitFor(() => expect(state!.value).toBe('granted'));
emitChange('denied');
await vi.waitFor(() => expect(state!.value).toBe('denied'));
scope.stop();
});
it('binds the change listener exactly once after resolution', async () => {
const { navigator, status } = stubPermissions('granted');
const scope = effectScope();
let state: UsePermissionReturn;
scope.run(() => {
state = usePermission('camera', { navigator });
});
await vi.waitFor(() => expect(state!.value).toBe('granted'));
expect(status.addEventListener).toHaveBeenCalledTimes(1);
expect(status.addEventListener).toHaveBeenCalledWith('change', expect.any(Function), { passive: true });
scope.stop();
});
it('removes the change listener when the scope is disposed', async () => {
const { navigator, status } = stubPermissions('granted');
const scope = effectScope();
let state: UsePermissionReturn;
scope.run(() => {
state = usePermission('camera', { navigator });
});
await vi.waitFor(() => expect(state!.value).toBe('granted'));
scope.stop();
expect(status.removeEventListener).toHaveBeenCalledWith('change', expect.any(Function), { passive: true });
});
it('dedupes concurrent and repeated queries', async () => {
const { navigator, query } = stubPermissions('granted');
const scope = effectScope();
let result: any;
scope.run(() => {
result = usePermission('microphone', { controls: true, navigator });
});
// initial query() call from setup + two more concurrent calls
await Promise.all([result.query(), result.query()]);
// once resolved, subsequent calls reuse the cached status
await result.query();
expect(query).toHaveBeenCalledTimes(1);
scope.stop();
});
it('query resolves to the raw PermissionStatus with controls', async () => {
const { navigator, status } = stubPermissions('granted');
const scope = effectScope();
let result: any;
scope.run(() => {
result = usePermission('geolocation', { controls: true, navigator });
});
const resolved = await result.query();
expect(resolved).toBe(status);
scope.stop();
});
it('query resolves to undefined when unsupported', async () => {
const navigator = {} as Navigator;
const scope = effectScope();
let result: any;
scope.run(() => {
result = usePermission('geolocation', { controls: true, navigator });
});
expect(result.isSupported.value).toBeFalsy();
await expect(result.query()).resolves.toBeUndefined();
scope.stop();
});
it('falls back to "prompt" when the query rejects', async () => {
const query = vi.fn(async () => {
throw new TypeError('denied descriptor');
});
const navigator = { permissions: { query } } as unknown as Navigator;
const scope = effectScope();
let state: UsePermissionReturn;
scope.run(() => {
state = usePermission('push', { navigator });
});
await vi.waitFor(() => expect(state!.value).toBe('prompt'));
scope.stop();
});
it('accepts a descriptor object', async () => {
const { navigator, query } = stubPermissions('granted');
const scope = effectScope();
let state: UsePermissionReturn;
scope.run(() => {
state = usePermission({ name: 'push', userVisibleOnly: true } as PermissionDescriptor, { navigator });
});
await vi.waitFor(() => expect(state!.value).toBe('granted'));
expect(query).toHaveBeenCalledWith({ name: 'push', userVisibleOnly: true });
scope.stop();
});
});
@@ -0,0 +1,159 @@
import { shallowRef, toRaw } from 'vue';
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 { useEventListener } from '@/composables/browser/useEventListener';
/**
* Permission names not yet present in the lib DOM `PermissionName` union but
* supported by browsers behind the Permissions API.
*/
export type PermissionDescriptorNamePolyfill
= | 'accelerometer'
| 'accessibility-events'
| 'ambient-light-sensor'
| 'background-sync'
| 'camera'
| 'clipboard-read'
| 'clipboard-write'
| 'geolocation'
| 'gyroscope'
| 'local-fonts'
| 'magnetometer'
| 'microphone'
| 'midi'
| 'notifications'
| 'payment-handler'
| 'persistent-storage'
| 'push'
| 'screen-wake-lock'
| 'speaker'
| 'speaker-selection'
| 'storage-access'
| 'window-management';
export type GeneralPermissionDescriptor
= | PermissionDescriptor
| { name: PermissionDescriptorNamePolyfill };
export interface UsePermissionOptions<Controls extends boolean> extends ConfigurableNavigator {
/**
* Expose the `isSupported` flag and a `query` method that returns the raw `PermissionStatus`
*
* @default false
*/
controls?: Controls;
}
export type UsePermissionReturn = Readonly<Ref<PermissionState | undefined>>;
export interface UsePermissionReturnWithControls {
/**
* Reactive permission state (`granted` | `denied` | `prompt`), or `undefined` while unsupported/unresolved
*/
state: UsePermissionReturn;
/**
* Whether the Permissions API is available
*/
isSupported: Readonly<Ref<boolean>>;
/**
* Query (or re-query) the permission, resolving to the raw `PermissionStatus`
*/
query: () => Promise<PermissionStatus | undefined>;
}
/**
* @name usePermission
* @category Browser
* @description Reactive Permissions API state.
*
* @param {GeneralPermissionDescriptor | string} permissionDesc The permission to query
* @param {UsePermissionOptions} [options={}] Options
* @returns {UsePermissionReturn | UsePermissionReturnWithControls} The permission state, or controls when `controls: true`
*
* @example
* const microphone = usePermission('microphone');
*
* @example
* const { state, isSupported, query } = usePermission('camera', { controls: true });
*
* @since 0.0.15
*/
export function usePermission(
permissionDesc: GeneralPermissionDescriptor | GeneralPermissionDescriptor['name'],
options?: UsePermissionOptions<false>,
): UsePermissionReturn;
export function usePermission(
permissionDesc: GeneralPermissionDescriptor | GeneralPermissionDescriptor['name'],
options: UsePermissionOptions<true>,
): UsePermissionReturnWithControls;
export function usePermission(
permissionDesc: GeneralPermissionDescriptor | GeneralPermissionDescriptor['name'],
options: UsePermissionOptions<boolean> = {},
): UsePermissionReturn | UsePermissionReturnWithControls {
const { controls = false, navigator = defaultNavigator } = options;
const isSupported = useSupported(() => !!navigator && 'permissions' in navigator);
const desc = (isString(permissionDesc)
? { name: permissionDesc }
: permissionDesc) as PermissionDescriptor;
// Shallow refs: `PermissionStatus` is a host object, deep reactivity is wasteful.
const permissionStatus: ShallowRef<PermissionStatus | undefined> = shallowRef();
const state: ShallowRef<PermissionState | undefined> = shallowRef();
const update = (): void => {
state.value = permissionStatus.value?.state ?? 'prompt';
};
// Register the `change` listener synchronously against the reactive ref so it
// auto-rebinds when the status resolves and auto-cleans on scope dispose.
useEventListener(permissionStatus, 'change', update, { passive: true });
// Dedupe concurrent/repeat calls: once a query is in flight we reuse it.
let queryPromise: Promise<PermissionStatus | undefined> | undefined;
const query = (): Promise<PermissionStatus | undefined> => {
if (!isSupported.value)
return Promise.resolve(undefined);
if (permissionStatus.value)
return Promise.resolve(permissionStatus.value);
if (queryPromise)
return queryPromise;
queryPromise = navigator!.permissions
.query(desc)
.then((status) => {
permissionStatus.value = status;
return status;
})
.catch(() => {
permissionStatus.value = undefined;
return undefined;
})
.finally(() => {
update();
queryPromise = undefined;
});
return queryPromise;
};
query();
if (controls) {
return {
state: state as UsePermissionReturn,
isSupported,
// `toRaw` so callers get the underlying `PermissionStatus`, not a reactive proxy.
query: () => query().then(status => (status ? toRaw(status) : undefined)),
};
}
return state as UsePermissionReturn;
}
@@ -0,0 +1,218 @@
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();
});
});
@@ -0,0 +1,162 @@
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,53 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick } from 'vue';
import { usePreferredColorScheme } from '.';
function stubScheme(scheme: 'dark' | 'light' | 'none') {
vi.stubGlobal('matchMedia', vi.fn((media: string) => ({
matches: scheme !== 'none' && media.includes(scheme),
media,
addEventListener: () => {},
removeEventListener: () => {},
})));
}
describe(usePreferredColorScheme, () => {
beforeEach(() => vi.stubGlobal('matchMedia', undefined));
afterEach(() => vi.unstubAllGlobals());
it('returns dark when dark is preferred', async () => {
stubScheme('dark');
const scope = effectScope();
let scheme: ReturnType<typeof usePreferredColorScheme>;
scope.run(() => {
scheme = usePreferredColorScheme();
});
await nextTick();
expect(scheme!.value).toBe('dark');
scope.stop();
});
it('returns light when light is preferred', async () => {
stubScheme('light');
const scope = effectScope();
let scheme: ReturnType<typeof usePreferredColorScheme>;
scope.run(() => {
scheme = usePreferredColorScheme();
});
await nextTick();
expect(scheme!.value).toBe('light');
scope.stop();
});
it('returns no-preference when neither matches', async () => {
stubScheme('none');
const scope = effectScope();
let scheme: ReturnType<typeof usePreferredColorScheme>;
scope.run(() => {
scheme = usePreferredColorScheme();
});
await nextTick();
expect(scheme!.value).toBe('no-preference');
scope.stop();
});
});
@@ -0,0 +1,36 @@
import { computed } from 'vue';
import type { ComputedRef } from 'vue';
import type { ConfigurableWindow } from '@/types';
import { useMediaQuery } from '@/composables/browser/useMediaQuery';
export type ColorSchemePreference = 'dark' | 'light' | 'no-preference';
/**
* @name usePreferredColorScheme
* @category Browser
* @description Reactive `prefers-color-scheme` media query.
*
* @param {ConfigurableWindow} [options={}] Options
* @returns {ComputedRef<ColorSchemePreference>} `'dark'`, `'light'`, or `'no-preference'`
*
* @example
* const scheme = usePreferredColorScheme();
*
* @since 0.0.15
*/
export function usePreferredColorScheme(
options: ConfigurableWindow = {},
): ComputedRef<ColorSchemePreference> {
const isLight = useMediaQuery('(prefers-color-scheme: light)', options);
const isDark = useMediaQuery('(prefers-color-scheme: dark)', options);
return computed(() => {
if (isDark.value)
return 'dark';
if (isLight.value)
return 'light';
return 'no-preference';
});
}
@@ -0,0 +1,27 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick } from 'vue';
import { usePreferredDark } from '.';
describe(usePreferredDark, () => {
beforeEach(() => vi.stubGlobal('matchMedia', undefined));
afterEach(() => vi.unstubAllGlobals());
it('reflects the prefers-color-scheme: dark query', async () => {
vi.stubGlobal('matchMedia', vi.fn((media: string) => ({
matches: media.includes('dark'),
media,
addEventListener: () => {},
removeEventListener: () => {},
})));
const scope = effectScope();
let isDark: ReturnType<typeof usePreferredDark>;
scope.run(() => {
isDark = usePreferredDark();
});
await nextTick();
expect(isDark!.value).toBeTruthy();
scope.stop();
});
});
@@ -0,0 +1,20 @@
import type { Ref } from 'vue';
import type { ConfigurableWindow } from '@/types';
import { useMediaQuery } from '@/composables/browser/useMediaQuery';
/**
* @name usePreferredDark
* @category Browser
* @description Reactive `prefers-color-scheme: dark` media query.
*
* @param {ConfigurableWindow} [options={}] Options
* @returns {Ref<boolean>} Whether the user prefers a dark color scheme
*
* @example
* const isDark = usePreferredDark();
*
* @since 0.0.15
*/
export function usePreferredDark(options: ConfigurableWindow = {}): Ref<boolean> {
return useMediaQuery('(prefers-color-scheme: dark)', options);
}
@@ -1,4 +1,4 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { defineComponent, effectScope, nextTick } from 'vue'; import { defineComponent, effectScope, nextTick } from 'vue';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { useRafFn } from '.'; import { useRafFn } from '.';
@@ -0,0 +1,188 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, ref } from 'vue';
import { useResizeObserver } from '.';
let instances: Array<{ cb: ResizeObserverCallback; observe: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn> }> = [];
class StubResizeObserver {
observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
cb: ResizeObserverCallback;
constructor(cb: ResizeObserverCallback) {
this.cb = cb;
instances.push(this);
}
}
describe(useResizeObserver, () => {
beforeEach(() => {
instances = [];
vi.stubGlobal('ResizeObserver', StubResizeObserver);
});
afterEach(() => vi.unstubAllGlobals());
it('observes the target element', () => {
const el = document.createElement('div');
const scope = effectScope();
scope.run(() => useResizeObserver(ref(el), vi.fn()));
expect(instances).toHaveLength(1);
expect(instances[0]!.observe).toHaveBeenCalledWith(el, undefined);
scope.stop();
});
it('passes the box option through to observe', () => {
const el = document.createElement('div');
const scope = effectScope();
scope.run(() => useResizeObserver(ref(el), vi.fn(), { box: 'border-box' }));
expect(instances[0]!.observe).toHaveBeenCalledWith(el, { box: 'border-box' });
scope.stop();
});
it('observes an array of targets with a single observer', () => {
const a = document.createElement('div');
const b = document.createElement('div');
const scope = effectScope();
scope.run(() => useResizeObserver([ref(a), ref(b)], vi.fn()));
expect(instances).toHaveLength(1);
expect(instances[0]!.observe).toHaveBeenCalledWith(a, undefined);
expect(instances[0]!.observe).toHaveBeenCalledWith(b, undefined);
scope.stop();
});
it('supports a getter target', () => {
const el = document.createElement('div');
const scope = effectScope();
scope.run(() => useResizeObserver(() => el, vi.fn()));
expect(instances).toHaveLength(1);
expect(instances[0]!.observe).toHaveBeenCalledWith(el, undefined);
scope.stop();
});
it('disconnects on stop', () => {
const el = document.createElement('div');
const scope = effectScope();
let stop: () => void;
scope.run(() => {
stop = useResizeObserver(ref(el), vi.fn()).stop;
});
stop!();
expect(instances[0]!.disconnect).toHaveBeenCalled();
scope.stop();
});
it('invokes the callback with entries', () => {
const el = document.createElement('div');
const callback = vi.fn();
const scope = effectScope();
scope.run(() => useResizeObserver(ref(el), callback));
const entry = { contentRect: { width: 10, height: 20 } } as ResizeObserverEntry;
instances[0]!.cb([entry], instances[0] as unknown as ResizeObserver);
expect(callback).toHaveBeenCalledWith([entry], expect.anything());
scope.stop();
});
it('re-observes when the target ref changes', async () => {
const a = document.createElement('div');
const b = document.createElement('div');
const target = ref<HTMLElement>(a);
const scope = effectScope();
scope.run(() => useResizeObserver(target, vi.fn()));
expect(instances).toHaveLength(1);
expect(instances[0]!.observe).toHaveBeenCalledWith(a, undefined);
target.value = b;
await nextTick();
expect(instances[0]!.disconnect).toHaveBeenCalled();
expect(instances).toHaveLength(2);
expect(instances[1]!.observe).toHaveBeenCalledWith(b, undefined);
scope.stop();
});
it('does not create an observer for a null target', () => {
const target = ref<HTMLElement | null>(null);
const scope = effectScope();
scope.run(() => useResizeObserver(target, vi.fn()));
expect(instances).toHaveLength(0);
scope.stop();
});
it('starts observing when a null target is later assigned', async () => {
const el = document.createElement('div');
const target = ref<HTMLElement | null>(null);
const scope = effectScope();
scope.run(() => useResizeObserver(target, vi.fn()));
expect(instances).toHaveLength(0);
target.value = el;
await nextTick();
expect(instances).toHaveLength(1);
expect(instances[0]!.observe).toHaveBeenCalledWith(el, undefined);
scope.stop();
});
it('does not observe when immediate is false until resumed', async () => {
const el = document.createElement('div');
const scope = effectScope();
let controls!: ReturnType<typeof useResizeObserver>;
scope.run(() => {
controls = useResizeObserver(ref(el), vi.fn(), { immediate: false });
});
expect(controls.isActive.value).toBeFalsy();
expect(instances).toHaveLength(0);
controls.resume();
await nextTick();
expect(controls.isActive.value).toBeTruthy();
expect(instances).toHaveLength(1);
expect(instances[0]!.observe).toHaveBeenCalledWith(el, undefined);
scope.stop();
});
it('pause disconnects and flips isActive, resume re-observes', async () => {
const el = document.createElement('div');
const scope = effectScope();
let controls!: ReturnType<typeof useResizeObserver>;
scope.run(() => {
controls = useResizeObserver(ref(el), vi.fn());
});
expect(controls.isActive.value).toBeTruthy();
expect(instances).toHaveLength(1);
controls.pause();
expect(controls.isActive.value).toBeFalsy();
expect(instances[0]!.disconnect).toHaveBeenCalled();
controls.resume();
await nextTick();
expect(controls.isActive.value).toBeTruthy();
expect(instances).toHaveLength(2);
expect(instances[1]!.observe).toHaveBeenCalledWith(el, undefined);
scope.stop();
});
it('cleans up when the scope is disposed', () => {
const el = document.createElement('div');
const scope = effectScope();
scope.run(() => useResizeObserver(ref(el), vi.fn()));
expect(instances).toHaveLength(1);
scope.stop();
expect(instances[0]!.disconnect).toHaveBeenCalled();
});
});
@@ -0,0 +1,149 @@
import { computed, readonly, ref, watch } from 'vue';
import type { Ref } from 'vue';
import { toArray } from '@robonen/stdlib';
import type { ConfigurableWindow } from '@/types';
import { defaultWindow } from '@/types';
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
import { unrefElement } from '@/composables/component/unrefElement';
import { useSupported } from '@/composables/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,
};
}
@@ -0,0 +1,188 @@
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();
});
});
@@ -0,0 +1,121 @@
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,
};
}
@@ -0,0 +1,214 @@
import { describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, ref } from 'vue';
import { useScroll } from '.';
function makeScrollable(overrides: Partial<{
scrollWidth: number;
scrollHeight: number;
clientWidth: number;
clientHeight: number;
}> = {}) {
const el = document.createElement('div');
Object.defineProperties(el, {
scrollWidth: { value: overrides.scrollWidth ?? 1000, configurable: true },
scrollHeight: { value: overrides.scrollHeight ?? 1000, configurable: true },
clientWidth: { value: overrides.clientWidth ?? 100, configurable: true },
clientHeight: { value: overrides.clientHeight ?? 100, configurable: true },
});
el.scrollLeft = 0;
el.scrollTop = 0;
return el;
}
function withScope<T>(fn: () => T): { result: T; scope: ReturnType<typeof effectScope> } {
const scope = effectScope();
let result!: T;
scope.run(() => {
result = fn();
});
return { result, scope };
}
describe(useScroll, () => {
it('starts at the top-left with arrived state', () => {
const el = makeScrollable();
const { result, scope } = withScope(() => useScroll(ref(el)));
expect(result.x.value).toBe(0);
expect(result.y.value).toBe(0);
expect(result.arrivedState.top).toBeTruthy();
expect(result.arrivedState.left).toBeTruthy();
scope.stop();
});
it('updates position and isScrolling on scroll', async () => {
const el = makeScrollable();
const { result, scope } = withScope(() => useScroll(ref(el)));
el.scrollTop = 50;
el.scrollLeft = 20;
el.dispatchEvent(new Event('scroll'));
await nextTick();
expect(result.x.value).toBe(20);
expect(result.y.value).toBe(50);
expect(result.isScrolling.value).toBeTruthy();
expect(result.directions.bottom).toBeTruthy();
expect(result.directions.right).toBeTruthy();
scope.stop();
});
it('flags arrival at the bottom edge', async () => {
const el = makeScrollable();
const { result, scope } = withScope(() => useScroll(ref(el)));
el.scrollTop = 900; // 900 + 100 clientHeight >= 1000 scrollHeight
el.dispatchEvent(new Event('scroll'));
await nextTick();
expect(result.arrivedState.bottom).toBeTruthy();
scope.stop();
});
it('measures the initial scroll position on mount', () => {
const el = makeScrollable();
el.scrollLeft = 30;
el.scrollTop = 40;
const { result, scope } = withScope(() => useScroll(ref(el)));
expect(result.x.value).toBe(30);
expect(result.y.value).toBe(40);
expect(result.arrivedState.top).toBeFalsy();
expect(result.arrivedState.left).toBeFalsy();
scope.stop();
});
it('exposes measure() to re-sync without a scroll event', () => {
const el = makeScrollable();
const { result, scope } = withScope(() => useScroll(ref(el)));
expect(result.y.value).toBe(0);
el.scrollTop = 200;
result.measure();
expect(result.y.value).toBe(200);
// measure() must not fabricate directions.
expect(result.directions.bottom).toBeFalsy();
scope.stop();
});
it('respects the offset when computing arrived state', async () => {
const el = makeScrollable();
const { result, scope } = withScope(() => useScroll(ref(el), { offset: { top: 10, bottom: 50 } }));
el.scrollTop = 8; // <= offset.top (10) => still arrived at top
el.dispatchEvent(new Event('scroll'));
await nextTick();
expect(result.arrivedState.top).toBeTruthy();
el.scrollTop = 855; // 855 + 100 >= 1000 - 50 - 1 => arrived at bottom early
el.dispatchEvent(new Event('scroll'));
await nextTick();
expect(result.arrivedState.bottom).toBeTruthy();
scope.stop();
});
it('resets isScrolling and directions and calls onStop after idle', async () => {
vi.useFakeTimers();
const onStop = vi.fn();
const el = makeScrollable();
const { result, scope } = withScope(() => useScroll(ref(el), { idle: 100, onStop }));
el.scrollTop = 50;
el.dispatchEvent(new Event('scroll'));
expect(result.isScrolling.value).toBeTruthy();
expect(result.directions.bottom).toBeTruthy();
vi.advanceTimersByTime(150);
await nextTick();
expect(result.isScrolling.value).toBeFalsy();
expect(result.directions.bottom).toBeFalsy();
expect(onStop).toHaveBeenCalledTimes(1);
scope.stop();
vi.useRealTimers();
});
it('normalises a negative (RTL) scrollLeft for arrived state', async () => {
const el = makeScrollable();
const styleSpy = vi.spyOn(globalThis, 'getComputedStyle').mockReturnValue({ direction: 'rtl' } as CSSStyleDeclaration);
const { result, scope } = withScope(() => useScroll(ref(el)));
// RTL: scrolled to the far end reports a large negative magnitude.
el.scrollLeft = -900;
el.dispatchEvent(new Event('scroll'));
await nextTick();
// |900| + 100 clientWidth >= 1000 scrollWidth => arrived at the right edge.
expect(result.arrivedState.right).toBeTruthy();
expect(result.arrivedState.left).toBeFalsy();
styleSpy.mockRestore();
scope.stop();
});
it('writes scroll position through the x/y setters with behavior', () => {
const el = makeScrollable();
const scrollToSpy = vi.fn();
el.scrollTo = scrollToSpy as typeof el.scrollTo;
const { result, scope } = withScope(() => useScroll(ref(el), { behavior: 'smooth' }));
result.y.value = 120;
expect(scrollToSpy).toHaveBeenCalledWith({ top: 120, behavior: 'smooth' });
result.x.value = 60;
expect(scrollToSpy).toHaveBeenCalledWith({ left: 60, behavior: 'smooth' });
scope.stop();
});
it('invokes onError when reading metrics throws', () => {
const el = makeScrollable();
const onError = vi.fn();
const styleSpy = vi.spyOn(globalThis, 'getComputedStyle').mockImplementation(() => {
throw new Error('detached');
});
const { scope } = withScope(() => useScroll(ref(el), { onError }));
expect(onError).toHaveBeenCalled();
styleSpy.mockRestore();
scope.stop();
});
it('does nothing when target is nullish', () => {
const { result, scope } = withScope(() => useScroll(ref(null)));
expect(result.x.value).toBe(0);
expect(result.y.value).toBe(0);
expect(() => result.measure()).not.toThrow();
scope.stop();
});
it('throttles scroll updates when throttle is set', async () => {
vi.useFakeTimers();
const onScroll = vi.fn();
const el = makeScrollable();
const { scope } = withScope(() => useScroll(ref(el), { throttle: 100, onScroll }));
el.scrollTop = 10;
el.dispatchEvent(new Event('scroll'));
el.scrollTop = 20;
el.dispatchEvent(new Event('scroll'));
el.scrollTop = 30;
el.dispatchEvent(new Event('scroll'));
// Leading edge fires once immediately, the rest are throttled.
expect(onScroll).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(150);
// Trailing edge flushes the latest.
expect(onScroll).toHaveBeenCalledTimes(2);
scope.stop();
vi.useRealTimers();
});
});
@@ -0,0 +1,339 @@
import { computed, reactive, shallowRef, toValue } from 'vue';
import type { MaybeRefOrGetter, Ref } from 'vue';
import { noop } from '@robonen/stdlib';
import type { ConfigurableWindow } from '@/types';
import { defaultWindow } from '@/types';
import { useEventListener } from '@/composables/browser/useEventListener';
import { useMutationObserver } from '@/composables/browser/useMutationObserver';
import { useThrottleFn } from '@/composables/utilities/useThrottleFn';
import { useDebounceFn } from '@/composables/utilities/useDebounceFn';
export interface UseScrollOffset {
left?: number;
right?: number;
top?: number;
bottom?: number;
}
export interface UseScrollObserveOptions {
/**
* Re-measure the arrived/position state whenever the target's subtree
* mutates (children added/removed, attributes changed). Useful when the
* scrollable content grows or shrinks without a scroll event firing.
*
* @default false
*/
mutation?: boolean;
}
export interface UseScrollOptions extends ConfigurableWindow {
/**
* Throttle delay (ms) for scroll position updates. `0` disables throttling.
*
* @default 0
*/
throttle?: number;
/**
* Idle time (ms) before `isScrolling` is reset to `false`
*
* @default 200
*/
idle?: number;
/**
* Distance (px) from each edge at which `arrivedState` flips to `true`
*
* @default { left: 0, right: 0, top: 0, bottom: 0 }
*/
offset?: UseScrollOffset;
/**
* Called on every scroll event
*/
onScroll?: (event: Event) => void;
/**
* Called when scrolling stops (after `idle`)
*/
onStop?: (event: Event) => void;
/**
* Listener options for the scroll event
*
* @default { capture: false, passive: true }
*/
eventListenerOptions?: boolean | AddEventListenerOptions;
/**
* Scroll behavior used when writing to `x`/`y`
*
* @default 'auto'
*/
behavior?: ScrollBehavior;
/**
* Re-measure the scroll state on DOM mutations of the target.
* Pass `true` to enable the default (`{ mutation: true }`).
*
* @default false
*/
observe?: boolean | UseScrollObserveOptions;
/**
* Error handler invoked when reading scroll metrics or computed style throws
* (e.g. a detached or cross-origin element).
*
* @default console.error
*/
onError?: (error: unknown) => void;
}
export type UseScrollTarget = MaybeRefOrGetter<HTMLElement | SVGElement | Window | Document | null | undefined>;
export interface UseScrollEdgeState {
left: boolean;
right: boolean;
top: boolean;
bottom: boolean;
}
export interface UseScrollReturn {
x: Ref<number>;
y: Ref<number>;
isScrolling: Ref<boolean>;
arrivedState: UseScrollEdgeState;
directions: UseScrollEdgeState;
/**
* Recompute `x`, `y`, `arrivedState`, and `directions` from the current DOM
* state. Call after a programmatic layout change that did not emit a scroll
* event.
*/
measure: () => void;
}
const ARRIVED_STATE_THRESHOLD_PIXELS = 1;
interface ScrollMetrics {
scrollLeft: number;
scrollTop: number;
scrollWidth: number;
scrollHeight: number;
clientWidth: number;
clientHeight: number;
/**
* `-1` when the element is laid out right-to-left, `1` otherwise. Used to
* normalise the (possibly negative) `scrollLeft` reported under RTL.
*/
directionMultiplier: number;
}
function isWindow(value: unknown, window: Window | undefined): value is Window {
return value === window || (typeof Window !== 'undefined' && value instanceof Window);
}
function getScrollMetrics(
el: HTMLElement | SVGElement | Window | Document,
window: Window,
): ScrollMetrics {
if (isWindow(el, window)) {
const doc = window.document.documentElement;
return {
scrollLeft: window.scrollX,
scrollTop: window.scrollY,
scrollWidth: doc.scrollWidth,
scrollHeight: doc.scrollHeight,
clientWidth: window.innerWidth,
clientHeight: window.innerHeight,
directionMultiplier: getDirectionMultiplier(doc, window),
};
}
const node = (el instanceof Document ? el.documentElement : el) as HTMLElement;
return {
scrollLeft: node.scrollLeft,
scrollTop: node.scrollTop,
scrollWidth: node.scrollWidth,
scrollHeight: node.scrollHeight,
clientWidth: node.clientWidth,
clientHeight: node.clientHeight,
directionMultiplier: getDirectionMultiplier(node, window),
};
}
function getDirectionMultiplier(node: Element, window: Window): number {
// getComputedStyle can throw on detached nodes; callers wrap this in try/catch.
return window.getComputedStyle(node).direction === 'rtl' ? -1 : 1;
}
/**
* @name useScroll
* @category Browser
* @description Reactive scroll position and state for an element or the window,
* with arrived-edge detection (RTL-aware), scroll directions, an `isScrolling`
* flag, optional throttling, and a `measure()` method for manual re-sync.
*
* @param {UseScrollTarget} target The scroll container (can be reactive)
* @param {UseScrollOptions} [options={}] Options
* @returns {UseScrollReturn} Reactive position, scroll state, arrived edges, directions, and `measure`
*
* @example
* const { x, y, isScrolling, arrivedState, measure } = useScroll(el);
*
* @since 0.0.15
*/
export function useScroll(
target: UseScrollTarget,
options: UseScrollOptions = {},
): UseScrollReturn {
const {
throttle = 0,
idle = 200,
onStop = noop,
onScroll = noop,
offset = {},
eventListenerOptions = { capture: false, passive: true },
behavior = 'auto',
window = defaultWindow,
observe: observeOption = false,
onError = noop,
} = options;
const internalX = shallowRef(0);
const internalY = shallowRef(0);
const isScrolling = shallowRef(false);
const arrivedState = reactive<UseScrollEdgeState>({ left: true, right: false, top: true, bottom: false });
const directions = reactive<UseScrollEdgeState>({ left: false, right: false, top: false, bottom: false });
const scrollTo = (axis: 'x' | 'y', value: number): void => {
const el = toValue(target);
if (!el)
return;
(el instanceof Document ? el.documentElement : el as HTMLElement | Window).scrollTo(
axis === 'x' ? { left: value, behavior } : { top: value, behavior },
);
};
const x = computed<number>({
get: () => internalX.value,
set: value => scrollTo('x', value),
});
const y = computed<number>({
get: () => internalY.value,
set: value => scrollTo('y', value),
});
const setArrivedState = (m: ScrollMetrics): void => {
// RTL elements report a negative scrollLeft; normalise to a magnitude so
// edge maths is identical to the LTR case.
const left = Math.abs(m.scrollLeft);
const top = Math.abs(m.scrollTop);
arrivedState.left = left <= (offset.left ?? 0);
arrivedState.right = left + m.clientWidth >= m.scrollWidth - (offset.right ?? 0) - ARRIVED_STATE_THRESHOLD_PIXELS;
arrivedState.top = top <= (offset.top ?? 0);
arrivedState.bottom = top + m.clientHeight >= m.scrollHeight - (offset.bottom ?? 0) - ARRIVED_STATE_THRESHOLD_PIXELS;
};
// `trackDirections` only applies when driven by a real scroll event; a manual
// measure() should not invent directions, so it is skipped there.
const sync = (trackDirections: boolean): void => {
const el = toValue(target);
if (!el || !window)
return;
let m: ScrollMetrics;
try {
m = getScrollMetrics(el, window);
}
catch (error) {
onError(error);
return;
}
const left = m.scrollLeft;
const top = m.scrollTop;
if (trackDirections) {
directions.left = left < internalX.value;
directions.right = left > internalX.value;
directions.top = top < internalY.value;
directions.bottom = top > internalY.value;
}
setArrivedState(m);
internalX.value = left;
internalY.value = top;
};
const measure = (): void => sync(false);
const onScrollEnd = useDebounceFn((event: Event) => {
// Guard against the debounce trailing edge firing after we already settled.
if (!isScrolling.value)
return;
isScrolling.value = false;
directions.left = false;
directions.right = false;
directions.top = false;
directions.bottom = false;
onStop(event);
}, throttle + idle);
const onScrollHandler = (event: Event): void => {
if (!toValue(target) || !window)
return;
sync(true);
isScrolling.value = true;
onScrollEnd(event);
onScroll(event);
};
const handler = throttle > 0
? useThrottleFn(onScrollHandler, throttle, true, true)
: onScrollHandler;
useEventListener(
target as MaybeRefOrGetter<EventTarget | null | undefined>,
'scroll',
handler as (event: Event) => void,
eventListenerOptions,
);
// Initial measure once a target is resolvable so x/y/arrivedState reflect the
// real starting position instead of the optimistic top-left defaults.
measure();
const observe = observeOption === true ? { mutation: true } : observeOption;
if (observe && observe.mutation) {
useMutationObserver(
// Window/Document are not observable elements; only observe real elements.
() => {
const el = toValue(target);
return el && !isWindow(el, window) && !(el instanceof Document) ? el : null;
},
() => measure(),
{ window, attributes: true, childList: true, subtree: true },
);
}
return {
x,
y,
isScrolling,
arrivedState,
directions,
measure,
};
}
@@ -0,0 +1,365 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { effectScope, isReadonly, nextTick, ref, shallowRef } from 'vue';
import { useScrollLock } from '.';
function makeIOSNavigator(): Navigator {
return {
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)',
platform: 'iPhone',
maxTouchPoints: 5,
} as unknown as Navigator;
}
function makeDesktopNavigator(): Navigator {
return {
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
platform: 'MacIntel',
maxTouchPoints: 0,
} as unknown as Navigator;
}
describe(useScrollLock, () => {
afterEach(() => vi.unstubAllGlobals());
it('returns a writable boolean ref (not readonly)', () => {
const el = document.createElement('div');
const scope = effectScope();
let isLocked: ReturnType<typeof useScrollLock>;
scope.run(() => {
isLocked = useScrollLock(ref(el));
});
expect(isLocked!.value).toBeFalsy();
expect(isReadonly(isLocked!)).toBeFalsy();
scope.stop();
});
it('respects the initialState argument and applies overflow when the element resolves', async () => {
const el = document.createElement('div');
const scope = effectScope();
let isLocked: ReturnType<typeof useScrollLock>;
scope.run(() => {
isLocked = useScrollLock(ref(el), true);
});
await nextTick();
expect(isLocked!.value).toBeTruthy();
expect(el.style.overflow).toBe('hidden');
scope.stop();
});
it('sets overflow:hidden when locked via the setter', () => {
const el = document.createElement('div');
const scope = effectScope();
let isLocked: ReturnType<typeof useScrollLock>;
scope.run(() => {
isLocked = useScrollLock(ref(el), false, { navigator: makeDesktopNavigator() });
});
expect(el.style.overflow).toBe('');
isLocked!.value = true;
expect(el.style.overflow).toBe('hidden');
expect(isLocked!.value).toBeTruthy();
scope.stop();
});
it('restores the prior overflow value on unlock', () => {
const el = document.createElement('div');
el.style.overflow = 'auto';
const scope = effectScope();
let isLocked: ReturnType<typeof useScrollLock>;
scope.run(() => {
isLocked = useScrollLock(ref(el), false, { navigator: makeDesktopNavigator() });
});
isLocked!.value = true;
expect(el.style.overflow).toBe('hidden');
isLocked!.value = false;
expect(el.style.overflow).toBe('auto');
expect(isLocked!.value).toBeFalsy();
scope.stop();
});
it('restores an empty inline overflow when none was set before locking', () => {
const el = document.createElement('div');
const scope = effectScope();
let isLocked: ReturnType<typeof useScrollLock>;
scope.run(() => {
isLocked = useScrollLock(ref(el), false, { navigator: makeDesktopNavigator() });
});
isLocked!.value = true;
expect(el.style.overflow).toBe('hidden');
isLocked!.value = false;
expect(el.style.overflow).toBe('');
scope.stop();
});
it('lock is idempotent — setting true twice does not change state', () => {
const el = document.createElement('div');
el.style.overflow = 'scroll';
const scope = effectScope();
let isLocked: ReturnType<typeof useScrollLock>;
scope.run(() => {
isLocked = useScrollLock(ref(el), false, { navigator: makeDesktopNavigator() });
});
isLocked!.value = true;
isLocked!.value = true;
expect(el.style.overflow).toBe('hidden');
isLocked!.value = false;
// The original 'scroll' is preserved across the double-lock.
expect(el.style.overflow).toBe('scroll');
scope.stop();
});
it('treats an element that already has overflow:hidden as locked', async () => {
const el = document.createElement('div');
el.style.overflow = 'hidden';
const scope = effectScope();
let isLocked: ReturnType<typeof useScrollLock>;
scope.run(() => {
isLocked = useScrollLock(ref(el));
});
await nextTick();
expect(isLocked!.value).toBeTruthy();
scope.stop();
});
it('applies the lock once a lazily-resolved element appears', async () => {
const target = shallowRef<HTMLElement | null>(null);
const scope = effectScope();
let isLocked: ReturnType<typeof useScrollLock>;
scope.run(() => {
isLocked = useScrollLock(target);
});
isLocked!.value = true;
// No element yet, so nothing to lock.
expect(isLocked!.value).toBeFalsy();
const el = document.createElement('div');
target.value = el;
await nextTick();
// The watcher sees isLocked still false; lock again now that the el exists.
isLocked!.value = true;
expect(el.style.overflow).toBe('hidden');
scope.stop();
});
it('does nothing when there is no element', () => {
const scope = effectScope();
let isLocked: ReturnType<typeof useScrollLock>;
scope.run(() => {
isLocked = useScrollLock(ref(null));
});
isLocked!.value = true;
expect(isLocked!.value).toBeFalsy();
isLocked!.value = false;
expect(isLocked!.value).toBeFalsy();
scope.stop();
});
it('registers a passive:false touchmove listener on iOS when locking', () => {
const el = document.createElement('div');
const addSpy = vi.spyOn(el, 'addEventListener');
const scope = effectScope();
let isLocked: ReturnType<typeof useScrollLock>;
scope.run(() => {
isLocked = useScrollLock(ref(el), false, { navigator: makeIOSNavigator() });
});
isLocked!.value = true;
expect(addSpy).toHaveBeenCalledWith(
'touchmove',
expect.any(Function),
expect.objectContaining({ passive: false }),
);
scope.stop();
});
it('does not register a touchmove listener on non-iOS platforms', () => {
const el = document.createElement('div');
const addSpy = vi.spyOn(el, 'addEventListener');
const scope = effectScope();
let isLocked: ReturnType<typeof useScrollLock>;
scope.run(() => {
isLocked = useScrollLock(ref(el), false, { navigator: makeDesktopNavigator() });
});
isLocked!.value = true;
expect(addSpy).not.toHaveBeenCalledWith('touchmove', expect.any(Function), expect.anything());
scope.stop();
});
it('removes the iOS touchmove listener on unlock', () => {
const el = document.createElement('div');
const removeSpy = vi.spyOn(el, 'removeEventListener');
const scope = effectScope();
let isLocked: ReturnType<typeof useScrollLock>;
scope.run(() => {
isLocked = useScrollLock(ref(el), false, { navigator: makeIOSNavigator() });
});
isLocked!.value = true;
isLocked!.value = false;
expect(removeSpy).toHaveBeenCalledWith('touchmove', expect.any(Function), expect.objectContaining({ passive: false }));
scope.stop();
});
it('prevents default on a single-touch touchmove over a non-scrollable target (iOS)', () => {
const el = document.createElement('div');
document.body.appendChild(el);
let captured: EventListener | undefined;
vi.spyOn(el, 'addEventListener').mockImplementation((type, listener) => {
if (type === 'touchmove')
captured = listener as EventListener;
});
const scope = effectScope();
let isLocked: ReturnType<typeof useScrollLock>;
scope.run(() => {
isLocked = useScrollLock(ref(el), false, { navigator: makeIOSNavigator() });
});
isLocked!.value = true;
expect(captured).toBeTypeOf('function');
const event = {
target: el,
touches: [{}],
cancelable: true,
preventDefault: vi.fn(),
} as unknown as TouchEvent;
captured!(event);
expect(event.preventDefault).toHaveBeenCalledTimes(1);
document.body.removeChild(el);
scope.stop();
});
it('does NOT prevent default for multi-touch gestures (iOS pinch-zoom)', () => {
const el = document.createElement('div');
document.body.appendChild(el);
let captured: EventListener | undefined;
vi.spyOn(el, 'addEventListener').mockImplementation((type, listener) => {
if (type === 'touchmove')
captured = listener as EventListener;
});
const scope = effectScope();
let isLocked: ReturnType<typeof useScrollLock>;
scope.run(() => {
isLocked = useScrollLock(ref(el), false, { navigator: makeIOSNavigator() });
});
isLocked!.value = true;
const event = {
target: el,
touches: [{}, {}],
cancelable: true,
preventDefault: vi.fn(),
} as unknown as TouchEvent;
captured!(event);
expect(event.preventDefault).not.toHaveBeenCalled();
document.body.removeChild(el);
scope.stop();
});
it('does NOT prevent default when the touch target is itself scrollable (iOS)', () => {
const el = document.createElement('div');
document.body.appendChild(el);
let captured: EventListener | undefined;
vi.spyOn(el, 'addEventListener').mockImplementation((type, listener) => {
if (type === 'touchmove')
captured = listener as EventListener;
});
const scrollable = document.createElement('div');
el.appendChild(scrollable);
// Force the inner element to look scrollable.
const win = {
getComputedStyle: (node: Element) =>
node === scrollable
? ({ overflowX: 'scroll', overflowY: 'scroll' } as CSSStyleDeclaration)
: ({ overflowX: 'visible', overflowY: 'visible' } as CSSStyleDeclaration),
} as unknown as Window;
const scope = effectScope();
let isLocked: ReturnType<typeof useScrollLock>;
scope.run(() => {
isLocked = useScrollLock(ref(el), false, { navigator: makeIOSNavigator(), window: win });
});
isLocked!.value = true;
const event = {
target: scrollable,
touches: [{}],
cancelable: true,
preventDefault: vi.fn(),
} as unknown as TouchEvent;
captured!(event);
expect(event.preventDefault).not.toHaveBeenCalled();
el.removeChild(scrollable);
document.body.removeChild(el);
scope.stop();
});
it('restores overflow and detaches the touchmove listener on scope dispose', () => {
// The composable registers tryOnScopeDispose(unlock), so disposing the scope
// restores the prior overflow and tears the iOS touchmove listener down even
// though the lock was triggered from the setter (outside the scope).
const el = document.createElement('div');
el.style.overflow = 'auto';
const removeSpy = vi.spyOn(el, 'removeEventListener');
const scope = effectScope();
let isLocked: ReturnType<typeof useScrollLock>;
scope.run(() => {
isLocked = useScrollLock(ref(el), false, { navigator: makeIOSNavigator() });
});
isLocked!.value = true;
expect(el.style.overflow).toBe('hidden');
scope.stop();
expect(el.style.overflow).toBe('auto');
expect(removeSpy).toHaveBeenCalledWith('touchmove', expect.any(Function), expect.anything());
});
it('does not register listeners when window is falsy (SSR)', () => {
const el = document.createElement('div');
const addSpy = vi.spyOn(el, 'addEventListener');
const scope = effectScope();
let isLocked: ReturnType<typeof useScrollLock>;
scope.run(() => {
isLocked = useScrollLock(ref(el), false, {
window: null as unknown as Window,
navigator: makeIOSNavigator(),
});
});
isLocked!.value = true;
// overflow is still toggled, but no iOS touchmove listener is attached.
expect(el.style.overflow).toBe('hidden');
expect(addSpy).not.toHaveBeenCalledWith('touchmove', expect.any(Function), expect.anything());
scope.stop();
});
});
@@ -0,0 +1,183 @@
import type { WritableComputedRef } from 'vue';
import { computed, shallowRef, toRef, watch } from 'vue';
import type { VoidFunction } from '@robonen/stdlib';
import type { ConfigurableNavigator, ConfigurableWindow } from '@/types';
import { defaultNavigator, 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';
type LockableElement = HTMLElement | SVGElement;
export interface UseScrollLockOptions extends ConfigurableWindow, ConfigurableNavigator {}
export type UseScrollLockReturn = WritableComputedRef<boolean>;
/**
* Stores each element's `style.overflow` value as it was the first time we
* touched it, so unlocking restores exactly what the author had set (rather
* than wiping inline overflow entirely). A WeakMap keeps this from pinning
* detached elements in memory.
*/
const elementInitialOverflow = /* #__PURE__ */ new WeakMap<LockableElement, string>();
function isIOS(navigator: Navigator | undefined): boolean {
if (!navigator)
return false;
const ua = navigator.userAgent;
// iPhone / iPod / legacy iPad, plus iPadOS 13+ which masquerades as MacIntel
// while reporting a touch screen.
return /iP(?:ad|hone|od)/.test(ua)
|| (navigator.platform === 'MacIntel' && (navigator.maxTouchPoints ?? 0) > 1);
}
/**
* Walks up from the touched node looking for an ancestor that can actually
* scroll. If one exists we must NOT prevent the touchmove, otherwise nested
* scroll regions (e.g. a modal body) become un-scrollable on iOS.
*/
function checkOverflowScroll(element: Element, window: Window): boolean {
const style = window.getComputedStyle(element);
if (
style.overflowX === 'scroll'
|| style.overflowY === 'scroll'
|| (style.overflowX === 'auto' && element.clientWidth < element.scrollWidth)
|| (style.overflowY === 'auto' && element.clientHeight < element.scrollHeight)
) {
return true;
}
const parent = element.parentNode as Element | null;
if (!parent || parent.tagName === 'BODY')
return false;
return checkOverflowScroll(parent, window);
}
function preventDefault(event: TouchEvent, window: Window): void {
const target = event.target as Element | null;
// Allow scrolling inside genuinely scrollable descendants.
if (target && checkOverflowScroll(target, window))
return;
// A multi-touch gesture (pinch-zoom) should be left alone.
if (event.touches.length > 1)
return;
if (event.cancelable)
event.preventDefault();
}
/**
* @name useScrollLock
* @category Browser
* @description Lock scrolling of an element by toggling `overflow: hidden`,
* preserving the element's prior inline overflow and handling iOS `touchmove`.
* Returns a writable boolean ref — set it to lock/unlock, read it for state.
*
* @param {MaybeComputedElementRef} element - The element (or template ref / getter) to lock.
* @param {boolean} [initialState] - Whether the element starts locked. Defaults to `false`.
* @param {UseScrollLockOptions} [options] - Configurable `window` / `navigator` (mainly for SSR & testing).
* @returns {UseScrollLockReturn} A writable boolean ref; `true` while locked.
*
* @example
* const el = useTemplateRef<HTMLElement>('el');
* const isLocked = useScrollLock(el);
* isLocked.value = true; // lock
* isLocked.value = false; // unlock
*
* @since 0.0.15
*/
export function useScrollLock(
element: MaybeComputedElementRef,
initialState = false,
options: UseScrollLockOptions = {},
): UseScrollLockReturn {
const { window = defaultWindow, navigator = defaultNavigator } = options;
const isLocked = shallowRef(initialState);
let stopTouchMoveListener: VoidFunction | null = null;
let initialOverflow = '';
watch(
toRef(element),
() => {
const el = unrefElement(element) as LockableElement | undefined;
if (!el)
return;
const style = el.style;
if (!elementInitialOverflow.has(el))
elementInitialOverflow.set(el, style.overflow);
if (style.overflow !== 'hidden')
initialOverflow = style.overflow;
// The element was already hidden before we attached — treat as locked.
if (style.overflow === 'hidden') {
isLocked.value = true;
return;
}
if (isLocked.value)
style.overflow = 'hidden';
},
{ immediate: true },
);
const lock = (): void => {
const el = unrefElement(element) as LockableElement | undefined;
if (!el || isLocked.value)
return;
if (window && isIOS(navigator)) {
stopTouchMoveListener = useEventListener(
el as HTMLElement,
'touchmove',
(event: Event) => preventDefault(event as TouchEvent, window),
{ passive: false },
);
}
el.style.overflow = 'hidden';
isLocked.value = true;
};
const unlock = (): void => {
const el = unrefElement(element) as LockableElement | undefined;
if (!el || !isLocked.value)
return;
stopTouchMoveListener?.();
stopTouchMoveListener = null;
el.style.overflow = initialOverflow;
elementInitialOverflow.delete(el);
isLocked.value = false;
};
// Restore overflow and detach the iOS touchmove listener when the owning
// scope is disposed, regardless of where the lock was triggered from.
tryOnScopeDispose(unlock);
return computed<boolean>({
get() {
return isLocked.value;
},
set(value) {
if (value)
lock();
else
unlock();
},
});
}
@@ -0,0 +1,119 @@
import { describe, expect, it, vi } from 'vitest';
import { effectScope, ref } from 'vue';
import { useShare } from '.';
import type { UseShareOptions } from '.';
function stubShareNavigator(canShareResult = true) {
const share = vi.fn(async (_data?: UseShareOptions) => {});
const canShare = vi.fn((_data?: UseShareOptions) => canShareResult);
const navigator = { share, canShare } as unknown as Navigator;
return { navigator, share, canShare };
}
function withScope<T>(fn: () => T): { result: T; stop: () => void } {
const scope = effectScope();
let result!: T;
scope.run(() => {
result = fn();
});
return { result, stop: () => scope.stop() };
}
describe(useShare, () => {
it('reports supported when the Web Share API is present', () => {
const { navigator } = stubShareNavigator();
const { result, stop } = withScope(() => useShare({}, { navigator }));
expect(result.isSupported.value).toBeTruthy();
stop();
});
it('reports unsupported when canShare is missing', () => {
const navigator = {} as Navigator;
const { result, stop } = withScope(() => useShare({}, { navigator }));
expect(result.isSupported.value).toBeFalsy();
stop();
});
it('calls navigator.share with the default share options', async () => {
const { navigator, share, canShare } = stubShareNavigator();
const data = { title: 'Hello', text: 'World', url: 'https://example.com' };
const { result, stop } = withScope(() => useShare(data, { navigator }));
await result.share();
expect(canShare).toHaveBeenCalledWith(data);
expect(share).toHaveBeenCalledWith(data);
stop();
});
it('merges overrideData over the default options', async () => {
const { navigator, share } = stubShareNavigator();
const { result, stop } = withScope(() =>
useShare({ title: 'Default', text: 'Default text' }, { navigator }),
);
await result.share({ text: 'Override text', url: 'https://override.dev' });
expect(share).toHaveBeenCalledWith({
title: 'Default',
text: 'Override text',
url: 'https://override.dev',
});
stop();
});
it('resolves reactive/getter share options at call time', async () => {
const { navigator, share } = stubShareNavigator();
const title = ref('first');
const { result, stop } = withScope(() => useShare(() => ({ title: title.value }), { navigator }));
await result.share();
expect(share).toHaveBeenLastCalledWith({ title: 'first' });
title.value = 'second';
await result.share();
expect(share).toHaveBeenLastCalledWith({ title: 'second' });
stop();
});
it('does not call share when canShare rejects the payload', async () => {
const { navigator, share, canShare } = stubShareNavigator(false);
const { result, stop } = withScope(() => useShare({ title: 'Nope' }, { navigator }));
await result.share();
expect(canShare).toHaveBeenCalled();
expect(share).not.toHaveBeenCalled();
stop();
});
it('skips canShare gating when canShare is unavailable', async () => {
const share = vi.fn(async () => {});
// No canShare -> isSupported is false, so share is a no-op.
const navigator = { share } as unknown as Navigator;
const { result, stop } = withScope(() => useShare({ title: 'Hi' }, { navigator }));
expect(result.isSupported.value).toBeFalsy();
await result.share();
expect(share).not.toHaveBeenCalled();
stop();
});
it('is a no-op when unsupported (SSR / missing navigator)', async () => {
const navigator = undefined as unknown as Navigator;
const { result, stop } = withScope(() => useShare({ title: 'Hi' }, { navigator }));
expect(result.isSupported.value).toBeFalsy();
await expect(result.share()).resolves.toBeUndefined();
stop();
});
it('returns the share promise from navigator.share', async () => {
const { navigator, share } = stubShareNavigator();
share.mockResolvedValueOnce(undefined);
const { result, stop } = withScope(() => useShare({ title: 'Hi' }, { navigator }));
await expect(result.share()).resolves.toBeUndefined();
stop();
});
});
@@ -0,0 +1,102 @@
import { toValue } from 'vue';
import type { MaybeRefOrGetter, Ref } from 'vue';
import { defaultNavigator } from '@/types';
import type { ConfigurableNavigator } from '@/types';
import { useSupported } from '@/composables/browser/useSupported';
export interface UseShareOptions {
/**
* Title of the shared content
*/
title?: string;
/**
* Arbitrary text that forms the body of the message being shared
*/
text?: string;
/**
* URL string referring to a resource being shared
*/
url?: string;
/**
* Array of `File` objects representing files to be shared
*/
files?: File[];
}
/**
* Subset of `Navigator` exposing the Web Share API surface, which is not yet in
* every lib DOM target.
*/
interface NavigatorWithShare {
share?: (data?: UseShareOptions) => Promise<void>;
canShare?: (data?: UseShareOptions) => boolean;
}
export interface UseShareReturn {
/**
* Whether the Web Share API is available
*/
isSupported: Readonly<Ref<boolean>>;
/**
* Invoke the native share sheet, optionally merging `overrideData` over the
* default share options. Resolves once sharing finishes (or is skipped when
* unsupported / the payload cannot be shared).
*/
share: (overrideData?: MaybeRefOrGetter<UseShareOptions>) => Promise<void>;
}
/**
* @name useShare
* @category Browser
* @description Reactive Web Share API wrapper to invoke the native share sheet.
*
* @param {MaybeRefOrGetter<UseShareOptions>} [shareOptions={}] Default share payload (title, text, url, files)
* @param {UseShareOptions} [options={}] Options
* @returns {UseShareReturn} `isSupported` flag and a `share` method
*
* @example
* const { share, isSupported } = useShare({ title: 'Hello', url: location.href });
* share();
*
* @example
* // Override the default payload per call
* const { share } = useShare({ title: 'Default' });
* share({ text: 'One-off message' });
*
* @since 0.0.15
*/
export function useShare(
shareOptions: MaybeRefOrGetter<UseShareOptions> = {},
options: ConfigurableNavigator = {},
): UseShareReturn {
const { navigator = defaultNavigator } = options;
const _navigator = navigator as NavigatorWithShare | undefined;
const isSupported = useSupported(() => !!_navigator && 'canShare' in _navigator);
const share = async (overrideData: MaybeRefOrGetter<UseShareOptions> = {}): Promise<void> => {
if (!isSupported.value || !_navigator)
return;
const data: UseShareOptions = {
...toValue(shareOptions),
...toValue(overrideData),
};
// `canShare` gates the payload (e.g. file types / size); only proceed when it
// accepts the data to avoid a guaranteed-to-reject `share()` call.
if (_navigator.canShare && !_navigator.canShare(data))
return;
return _navigator.share?.(data);
};
return {
isSupported,
share,
};
}
@@ -1,5 +1,5 @@
import { defineComponent } from 'vue'; import { defineComponent } from 'vue';
import { describe, it, expect } from 'vitest'; import { describe, expect, it } from 'vitest';
import { useSupported } from '.'; import { useSupported } from '.';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
@@ -21,9 +21,8 @@ export function useSupported(feature: () => unknown) {
const isMounted = useMounted(); const isMounted = useMounted();
return computed(() => { return computed(() => {
// add reactive dependency on isMounted // Touch isMounted to register it as a reactive dependency
// eslint-disable-next-line no-unused-expressions void isMounted.value;
isMounted.value;
return Boolean(feature()); return Boolean(feature());
}); });

Some files were not shown because too many files have changed in this diff Show More