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:
+23
-10
@@ -1,6 +1,6 @@
|
||||
# @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
|
||||
|
||||
@@ -10,19 +10,32 @@ pnpm install @robonen/vue
|
||||
|
||||
## Composables
|
||||
|
||||
| Category | Composables |
|
||||
| -------------- | ------------------------------------------------------------------ |
|
||||
| **browser** | `useEventListener`, `useFocusGuard`, `useSupported` |
|
||||
| **component** | `unrefElement`, `useRenderCount`, `useRenderInfo` |
|
||||
| Category | Composables |
|
||||
| -------------- | ----------- |
|
||||
| **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`, `useForwardExpose`, `useTemplateRefsList` |
|
||||
| **debug** | `useRenderCount`, `useRenderInfo` |
|
||||
| **lifecycle** | `tryOnBeforeMount`, `tryOnMounted`, `tryOnScopeDispose`, `useMounted` |
|
||||
| **math** | `useClamp` |
|
||||
| **reactivity** | `broadcastedRef`, `useCached`, `useLastChanged`, `useSyncRefs` |
|
||||
| **state** | `useAppSharedState`, `useAsyncState`, `useContextFactory`, `useCounter`, `useInjectionStore`, `useToggle` |
|
||||
| **math** | `useClamp` |
|
||||
| **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`, `useId`, `useInjectionStore`, `useStepper`, `useToggle` |
|
||||
| **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
|
||||
|
||||
```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();
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
import { base, compose, imports, stylistic, typescript, vitest, vue } from '@robonen/eslint';
|
||||
|
||||
export default compose(base, typescript, vue, vitest, imports, stylistic);
|
||||
@@ -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));
|
||||
@@ -37,19 +37,18 @@
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint:check": "oxlint -c oxlint.config.ts",
|
||||
"lint:fix": "oxlint -c oxlint.config.ts --fix",
|
||||
"lint:check": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "vitest run",
|
||||
"dev": "vitest dev",
|
||||
"build": "tsdown"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@robonen/oxlint": "workspace:*",
|
||||
"@robonen/eslint": "workspace:*",
|
||||
"@robonen/tsconfig": "workspace:*",
|
||||
"@robonen/tsdown": "workspace:*",
|
||||
"@stylistic/eslint-plugin": "catalog:",
|
||||
"@vue/test-utils": "catalog:",
|
||||
"oxlint": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"tsdown": "catalog:"
|
||||
},
|
||||
"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 './useEyeDropper';
|
||||
export * from './useFavicon';
|
||||
export * from './useFileDialog';
|
||||
export * from './useFocus';
|
||||
export * from './useFocusGuard';
|
||||
export * from './useFocusWithin';
|
||||
export * from './useFps';
|
||||
export * from './useFullscreen';
|
||||
export * from './useGeolocation';
|
||||
export * from './useIdle';
|
||||
export * from './useIntersectionObserver';
|
||||
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 './useResizeObserver';
|
||||
export * from './useScreenOrientation';
|
||||
export * from './useScroll';
|
||||
export * from './useScrollLock';
|
||||
export * from './useShare';
|
||||
export * from './useSupported';
|
||||
export * from './useSwipe';
|
||||
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 { mount } from '@vue/test-utils';
|
||||
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 { defineComponent, nextTick } from 'vue';
|
||||
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 { 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 { mount } from '@vue/test-utils';
|
||||
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 { mount } from '@vue/test-utils';
|
||||
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 { describe, it, expect } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { useSupported } from '.';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
|
||||
@@ -21,9 +21,8 @@ export function useSupported(feature: () => unknown) {
|
||||
const isMounted = useMounted();
|
||||
|
||||
return computed(() => {
|
||||
// add reactive dependency on isMounted
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
isMounted.value;
|
||||
// Touch isMounted to register it as a reactive dependency
|
||||
void isMounted.value;
|
||||
|
||||
return Boolean(feature());
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user