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:
+21
-8
@@ -1,6 +1,6 @@
|
|||||||
# @robonen/vue
|
# @robonen/vue
|
||||||
|
|
||||||
Collection of composables and utilities for Vue 3.
|
Collection of composables and utilities for Vue 3 — 100+ tree-shakeable, SSR-safe composables.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -11,18 +11,31 @@ pnpm install @robonen/vue
|
|||||||
## Composables
|
## Composables
|
||||||
|
|
||||||
| Category | Composables |
|
| Category | Composables |
|
||||||
| -------------- | ------------------------------------------------------------------ |
|
| -------------- | ----------- |
|
||||||
| **browser** | `useEventListener`, `useFocusGuard`, `useSupported` |
|
| **browser** | `onKeyStroke`, `useActiveElement`, `useBodyScrollLock`, `useClickOutside`, `useClipboard`, `useCloseWatcher`, `useColorMode`, `useDevicePixelRatio`, `useDocumentReadyState`, `useDocumentVisibility`, `useDropZone`, `useElementBounding`, `useElementHover`, `useElementSize`, `useElementVisibility`, `useEscapeKey`, `useEventListener`, `useEyeDropper`, `useFavicon`, `useFileDialog`, `useFocus`, `useFocusGuard`, `useFocusWithin`, `useFps`, `useFullscreen`, `useGeolocation`, `useIdle`, `useIntersectionObserver`, `useIntervalFn`, `useKeyModifier`, `useMagicKeys`, `useMediaQuery`, `useMouse`, `useMousePressed`, `useMutationObserver`, `useNetwork`, `useObjectUrl`, `useOnline`, `usePageLeave`, `usePermission`, `usePointer`, `usePreferredColorScheme`, `usePreferredDark`, `useRafFn`, `useResizeObserver`, `useScreenOrientation`, `useScroll`, `useScrollLock`, `useShare`, `useSupported`, `useSwipe`, `useTabLeader`, `useTextSelection`, `useTitle`, `useVibrate`, `useWindowFocus`, `useWindowScroll`, `useWindowSize` |
|
||||||
| **component** | `unrefElement`, `useRenderCount`, `useRenderInfo` |
|
| **component** | `unrefElement`, `useForwardExpose`, `useTemplateRefsList` |
|
||||||
|
| **debug** | `useRenderCount`, `useRenderInfo` |
|
||||||
| **lifecycle** | `tryOnBeforeMount`, `tryOnMounted`, `tryOnScopeDispose`, `useMounted` |
|
| **lifecycle** | `tryOnBeforeMount`, `tryOnMounted`, `tryOnScopeDispose`, `useMounted` |
|
||||||
| **math** | `useClamp` |
|
| **math** | `useClamp` |
|
||||||
| **reactivity** | `broadcastedRef`, `useCached`, `useLastChanged`, `useSyncRefs` |
|
| **reactivity** | `broadcastedRef`, `refAutoReset`, `refDebounced`, `refThrottled`, `until`, `useArrayFilter`, `useArrayFind`, `useArrayMap`, `useCached`, `useCloned`, `useCycleList`, `useLastChanged`, `usePrevious`, `useSyncRefs`, `useToNumber`, `useToString`, `watchDebounced`, `watchIgnorable`, `watchOnce`, `watchPausable`, `watchThrottled`, `whenever` |
|
||||||
| **state** | `useAppSharedState`, `useAsyncState`, `useContextFactory`, `useCounter`, `useInjectionStore`, `useToggle` |
|
| **state** | `useAppSharedState`, `useAsyncState`, `useContextFactory`, `useCounter`, `useId`, `useInjectionStore`, `useStepper`, `useToggle` |
|
||||||
| **storage** | `useLocalStorage`, `useSessionStorage`, `useStorage`, `useStorageAsync` |
|
| **storage** | `useLocalStorage`, `useSessionStorage`, `useStorage`, `useStorageAsync` |
|
||||||
| **utilities** | `useOffsetPagination` |
|
| **utilities** | `useDebounceFn`, `useInterval`, `useOffsetPagination`, `useThrottleFn`, `useTimeoutFn`, `useTimestamp` |
|
||||||
|
|
||||||
|
The package also exports event-filter helpers (`debounceFilter`, `throttleFilter`, `pausableFilter`, `createFilterWrapper`) and shared types (`ConfigurableWindow`, `ConfigurableDocument`, `ConfigurableNavigator`, `MaybeComputedElementRef`, …).
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { useToggle, useEventListener } from '@robonen/vue';
|
import { useEventListener, useMagicKeys, useToggle } from '@robonen/vue';
|
||||||
|
|
||||||
|
const { value, toggle } = useToggle();
|
||||||
|
|
||||||
|
useEventListener('scroll', () => {/* … */}, { passive: true });
|
||||||
|
|
||||||
|
const keys = useMagicKeys();
|
||||||
|
watchEffect(() => {
|
||||||
|
if (keys['ctrl+s'].value)
|
||||||
|
save();
|
||||||
|
});
|
||||||
```
|
```
|
||||||
@@ -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": {
|
"scripts": {
|
||||||
"lint:check": "oxlint -c oxlint.config.ts",
|
"lint:check": "eslint .",
|
||||||
"lint:fix": "oxlint -c oxlint.config.ts --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"dev": "vitest dev",
|
"dev": "vitest dev",
|
||||||
"build": "tsdown"
|
"build": "tsdown"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@robonen/oxlint": "workspace:*",
|
"@robonen/eslint": "workspace:*",
|
||||||
"@robonen/tsconfig": "workspace:*",
|
"@robonen/tsconfig": "workspace:*",
|
||||||
"@robonen/tsdown": "workspace:*",
|
"@robonen/tsdown": "workspace:*",
|
||||||
"@stylistic/eslint-plugin": "catalog:",
|
|
||||||
"@vue/test-utils": "catalog:",
|
"@vue/test-utils": "catalog:",
|
||||||
"oxlint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"tsdown": "catalog:"
|
"tsdown": "catalog:"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,7 +1,58 @@
|
|||||||
|
export * from './onKeyStroke';
|
||||||
|
export * from './useActiveElement';
|
||||||
|
export * from './useBodyScrollLock';
|
||||||
|
export * from './useClickOutside';
|
||||||
|
export * from './useClipboard';
|
||||||
|
export * from './useCloseWatcher';
|
||||||
|
export * from './useColorMode';
|
||||||
|
export * from './useDevicePixelRatio';
|
||||||
|
export * from './useDocumentReadyState';
|
||||||
|
export * from './useDocumentVisibility';
|
||||||
|
export * from './useDropZone';
|
||||||
|
export * from './useElementBounding';
|
||||||
|
export * from './useElementHover';
|
||||||
|
export * from './useElementSize';
|
||||||
|
export * from './useElementVisibility';
|
||||||
|
export * from './useEscapeKey';
|
||||||
export * from './useEventListener';
|
export * from './useEventListener';
|
||||||
|
export * from './useEyeDropper';
|
||||||
|
export * from './useFavicon';
|
||||||
|
export * from './useFileDialog';
|
||||||
|
export * from './useFocus';
|
||||||
export * from './useFocusGuard';
|
export * from './useFocusGuard';
|
||||||
|
export * from './useFocusWithin';
|
||||||
export * from './useFps';
|
export * from './useFps';
|
||||||
|
export * from './useFullscreen';
|
||||||
|
export * from './useGeolocation';
|
||||||
|
export * from './useIdle';
|
||||||
|
export * from './useIntersectionObserver';
|
||||||
export * from './useIntervalFn';
|
export * from './useIntervalFn';
|
||||||
|
export * from './useKeyModifier';
|
||||||
|
export * from './useMagicKeys';
|
||||||
|
export * from './useMediaQuery';
|
||||||
|
export * from './useMouse';
|
||||||
|
export * from './useMousePressed';
|
||||||
|
export * from './useMutationObserver';
|
||||||
|
export * from './useNetwork';
|
||||||
|
export * from './useObjectUrl';
|
||||||
|
export * from './useOnline';
|
||||||
|
export * from './usePageLeave';
|
||||||
|
export * from './usePermission';
|
||||||
|
export * from './usePointer';
|
||||||
|
export * from './usePreferredColorScheme';
|
||||||
|
export * from './usePreferredDark';
|
||||||
export * from './useRafFn';
|
export * from './useRafFn';
|
||||||
|
export * from './useResizeObserver';
|
||||||
|
export * from './useScreenOrientation';
|
||||||
|
export * from './useScroll';
|
||||||
|
export * from './useScrollLock';
|
||||||
|
export * from './useShare';
|
||||||
export * from './useSupported';
|
export * from './useSupported';
|
||||||
|
export * from './useSwipe';
|
||||||
export * from './useTabLeader';
|
export * from './useTabLeader';
|
||||||
|
export * from './useTextSelection';
|
||||||
|
export * from './useTitle';
|
||||||
|
export * from './useVibrate';
|
||||||
|
export * from './useWindowFocus';
|
||||||
|
export * from './useWindowScroll';
|
||||||
|
export * from './useWindowSize';
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope, ref } from 'vue';
|
||||||
|
import { onKeyDown, onKeyPressed, onKeyStroke, onKeyUp } from '.';
|
||||||
|
|
||||||
|
function keydown(key: string, init: KeyboardEventInit = {}, target: EventTarget = globalThis) {
|
||||||
|
target.dispatchEvent(new KeyboardEvent('keydown', { key, ...init }));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(onKeyStroke, () => {
|
||||||
|
let stops: Array<() => void> = [];
|
||||||
|
|
||||||
|
function track(stop: () => void) {
|
||||||
|
stops.push(stop);
|
||||||
|
return stop;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
stops.forEach(stop => stop());
|
||||||
|
stops = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires the handler for a matching string key', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
track(onKeyStroke('a', handler));
|
||||||
|
|
||||||
|
keydown('a');
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
expect(handler.mock.calls[0]![0]).toBeInstanceOf(KeyboardEvent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores non-matching keys', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
track(onKeyStroke('a', handler));
|
||||||
|
|
||||||
|
keydown('b');
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches any key in an array filter', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
track(onKeyStroke(['a', 'b', 'c'], handler));
|
||||||
|
|
||||||
|
keydown('a');
|
||||||
|
keydown('b');
|
||||||
|
keydown('z');
|
||||||
|
expect(handler).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a predicate filter', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
track(onKeyStroke((e: KeyboardEvent) => e.metaKey && e.key === 's', handler));
|
||||||
|
|
||||||
|
keydown('s');
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
keydown('s', { metaKey: true });
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches every key when filter is omitted', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
track(onKeyStroke(handler));
|
||||||
|
|
||||||
|
keydown('a');
|
||||||
|
keydown('b');
|
||||||
|
expect(handler).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches every key when filter is `true`', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
track(onKeyStroke(true, handler));
|
||||||
|
|
||||||
|
keydown('x');
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports the handler-plus-options overload', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const target = document.createElement('div');
|
||||||
|
track(onKeyStroke(handler, { target }));
|
||||||
|
|
||||||
|
keydown('a', {}, target);
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
keydown('a');
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attaches to a custom target', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const target = document.createElement('div');
|
||||||
|
track(onKeyStroke('a', handler, { target }));
|
||||||
|
|
||||||
|
keydown('a', {}, target);
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
keydown('a');
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listens on a custom eventName', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const target = document.createElement('div');
|
||||||
|
track(onKeyStroke('a', handler, { target, eventName: 'keyup' }));
|
||||||
|
|
||||||
|
keydown('a', {}, target);
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
target.dispatchEvent(new KeyboardEvent('keyup', { key: 'a' }));
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dedupe ignores auto-repeated events', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
track(onKeyStroke('a', handler, { dedupe: true }));
|
||||||
|
|
||||||
|
keydown('a', { repeat: false });
|
||||||
|
keydown('a', { repeat: true });
|
||||||
|
keydown('a', { repeat: true });
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dedupe accepts a reactive ref', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const dedupe = ref(false);
|
||||||
|
track(onKeyStroke('a', handler, { dedupe }));
|
||||||
|
|
||||||
|
keydown('a', { repeat: true });
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
dedupe.value = true;
|
||||||
|
keydown('a', { repeat: true });
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('without dedupe still fires for repeated events', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
track(onKeyStroke('a', handler));
|
||||||
|
|
||||||
|
keydown('a', { repeat: true });
|
||||||
|
keydown('a', { repeat: true });
|
||||||
|
expect(handler).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stop handle removes the listener', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const stop = onKeyStroke('a', handler);
|
||||||
|
|
||||||
|
stop();
|
||||||
|
keydown('a');
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleans up when the effect scope is disposed', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const scope = effectScope();
|
||||||
|
|
||||||
|
scope.run(() => {
|
||||||
|
onKeyStroke('a', handler);
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
keydown('a');
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a no-op when target is unavailable (SSR / unsupported)', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const stop = onKeyStroke('a', handler, { target: null });
|
||||||
|
|
||||||
|
expect(typeof stop).toBe('function');
|
||||||
|
keydown('a');
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
// stop should be safely callable
|
||||||
|
expect(() => stop()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(onKeyDown, () => {
|
||||||
|
let stop: (() => void) | undefined;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
stop?.();
|
||||||
|
stop = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listens for keydown', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const target = document.createElement('div');
|
||||||
|
stop = onKeyDown('a', handler, { target });
|
||||||
|
|
||||||
|
keydown('a', {}, target);
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(onKeyUp, () => {
|
||||||
|
let stop: (() => void) | undefined;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
stop?.();
|
||||||
|
stop = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listens for keyup', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const target = document.createElement('div');
|
||||||
|
stop = onKeyUp('a', handler, { target });
|
||||||
|
|
||||||
|
keydown('a', {}, target);
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
target.dispatchEvent(new KeyboardEvent('keyup', { key: 'a' }));
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(onKeyPressed, () => {
|
||||||
|
let stop: (() => void) | undefined;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
stop?.();
|
||||||
|
stop = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listens for keypress', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const target = document.createElement('div');
|
||||||
|
stop = onKeyPressed('a', handler, { target });
|
||||||
|
|
||||||
|
keydown('a', {}, target);
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
target.dispatchEvent(new KeyboardEvent('keypress', { key: 'a' }));
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import type { VoidFunction } from '@robonen/stdlib';
|
||||||
|
import { isArray, isFunction, isString, noop } from '@robonen/stdlib';
|
||||||
|
import { toValue } from 'vue';
|
||||||
|
import type { MaybeRefOrGetter } from 'vue';
|
||||||
|
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||||
|
import { defaultWindow } from '@/types';
|
||||||
|
|
||||||
|
export type KeyPredicate = (event: KeyboardEvent) => boolean;
|
||||||
|
export type KeyFilter = true | string | string[] | KeyPredicate;
|
||||||
|
export type KeyStrokeEventName = 'keydown' | 'keypress' | 'keyup';
|
||||||
|
|
||||||
|
export interface OnKeyStrokeOptions {
|
||||||
|
/**
|
||||||
|
* The keyboard event to listen for.
|
||||||
|
*
|
||||||
|
* @default 'keydown'
|
||||||
|
*/
|
||||||
|
eventName?: KeyStrokeEventName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The element to attach the listener to.
|
||||||
|
*
|
||||||
|
* @default window
|
||||||
|
*/
|
||||||
|
target?: MaybeRefOrGetter<EventTarget | null | undefined>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the listener as passive (cannot call `preventDefault`).
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
passive?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ignore auto-repeated keydown events while a key is held down.
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
dedupe?: MaybeRefOrGetter<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a predicate from a key filter.
|
||||||
|
*
|
||||||
|
* - `function` → used as-is.
|
||||||
|
* - `string` → matches `event.key`.
|
||||||
|
* - `string[]` → matches any key in the list.
|
||||||
|
* - `true`/anything else → matches every event.
|
||||||
|
*/
|
||||||
|
function createKeyPredicate(keyFilter: KeyFilter): KeyPredicate {
|
||||||
|
if (isFunction(keyFilter))
|
||||||
|
return keyFilter;
|
||||||
|
|
||||||
|
if (isString(keyFilter))
|
||||||
|
return (event: KeyboardEvent) => event.key === keyFilter;
|
||||||
|
|
||||||
|
if (isArray(keyFilter))
|
||||||
|
return (event: KeyboardEvent) => keyFilter.includes(event.key);
|
||||||
|
|
||||||
|
return () => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name onKeyStroke
|
||||||
|
* @category Browser
|
||||||
|
* @description Listen for keyboard strokes. Accepts a key, list of keys, or a predicate and
|
||||||
|
* fires the handler for matching events. Auto-cleans up on scope dispose.
|
||||||
|
*
|
||||||
|
* Overload 1: Explicit key filter
|
||||||
|
*/
|
||||||
|
export function onKeyStroke(key: KeyFilter, handler: (event: KeyboardEvent) => void, options?: OnKeyStrokeOptions): VoidFunction;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name onKeyStroke
|
||||||
|
* @category Browser
|
||||||
|
* @description Listen for every keyboard stroke (no key filter).
|
||||||
|
*
|
||||||
|
* Overload 2: Omitted key filter (matches all keys)
|
||||||
|
*
|
||||||
|
* @param {(event: KeyboardEvent) => void} handler Callback invoked on a matching key event
|
||||||
|
* @param {OnKeyStrokeOptions} [options] Listener configuration
|
||||||
|
* @returns {VoidFunction} Stop handle that removes the listener
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* onKeyStroke('ArrowDown', (e) => { e.preventDefault(); });
|
||||||
|
* onKeyStroke(['a', 'b', 'c'], (e) => console.log(e.key));
|
||||||
|
* onKeyStroke((e) => e.metaKey && e.key === 's', save, { eventName: 'keydown' });
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function onKeyStroke(handler: (event: KeyboardEvent) => void, options?: OnKeyStrokeOptions): VoidFunction;
|
||||||
|
|
||||||
|
export function onKeyStroke(...args: any[]): VoidFunction {
|
||||||
|
let key: KeyFilter;
|
||||||
|
let handler: (event: KeyboardEvent) => void;
|
||||||
|
let options: OnKeyStrokeOptions = {};
|
||||||
|
|
||||||
|
if (args.length === 3) {
|
||||||
|
key = args[0];
|
||||||
|
handler = args[1];
|
||||||
|
options = args[2];
|
||||||
|
}
|
||||||
|
else if (args.length === 2) {
|
||||||
|
if (typeof args[1] === 'object') {
|
||||||
|
key = true;
|
||||||
|
handler = args[0];
|
||||||
|
options = args[1];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
key = args[0];
|
||||||
|
handler = args[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
key = true;
|
||||||
|
handler = args[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
target = defaultWindow,
|
||||||
|
eventName = 'keydown',
|
||||||
|
passive = false,
|
||||||
|
dedupe = false,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (!target)
|
||||||
|
return noop;
|
||||||
|
|
||||||
|
const predicate = createKeyPredicate(key);
|
||||||
|
|
||||||
|
const listener = (event: KeyboardEvent) => {
|
||||||
|
if (event.repeat && toValue(dedupe))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (predicate(event))
|
||||||
|
handler(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
return useEventListener(target, eventName, listener, { passive });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name onKeyDown
|
||||||
|
* @category Browser
|
||||||
|
* @description Listen for `keydown` strokes. Shorthand for `onKeyStroke` with `eventName: 'keydown'`.
|
||||||
|
*
|
||||||
|
* @param {KeyFilter} key Key, list of keys, or predicate to match
|
||||||
|
* @param {(event: KeyboardEvent) => void} handler Callback invoked on a matching key event
|
||||||
|
* @param {Omit<OnKeyStrokeOptions, 'eventName'>} [options] Listener configuration
|
||||||
|
* @returns {VoidFunction} Stop handle that removes the listener
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* onKeyDown('Enter', submit);
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function onKeyDown(key: KeyFilter, handler: (event: KeyboardEvent) => void, options: Omit<OnKeyStrokeOptions, 'eventName'> = {}): VoidFunction {
|
||||||
|
return onKeyStroke(key, handler, { ...options, eventName: 'keydown' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name onKeyUp
|
||||||
|
* @category Browser
|
||||||
|
* @description Listen for `keyup` strokes. Shorthand for `onKeyStroke` with `eventName: 'keyup'`.
|
||||||
|
*
|
||||||
|
* @param {KeyFilter} key Key, list of keys, or predicate to match
|
||||||
|
* @param {(event: KeyboardEvent) => void} handler Callback invoked on a matching key event
|
||||||
|
* @param {Omit<OnKeyStrokeOptions, 'eventName'>} [options] Listener configuration
|
||||||
|
* @returns {VoidFunction} Stop handle that removes the listener
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* onKeyUp('Escape', close);
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function onKeyUp(key: KeyFilter, handler: (event: KeyboardEvent) => void, options: Omit<OnKeyStrokeOptions, 'eventName'> = {}): VoidFunction {
|
||||||
|
return onKeyStroke(key, handler, { ...options, eventName: 'keyup' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name onKeyPressed
|
||||||
|
* @category Browser
|
||||||
|
* @description Listen for `keypress` strokes. Shorthand for `onKeyStroke` with `eventName: 'keypress'`.
|
||||||
|
*
|
||||||
|
* @param {KeyFilter} key Key, list of keys, or predicate to match
|
||||||
|
* @param {(event: KeyboardEvent) => void} handler Callback invoked on a matching key event
|
||||||
|
* @param {Omit<OnKeyStrokeOptions, 'eventName'>} [options] Listener configuration
|
||||||
|
* @returns {VoidFunction} Stop handle that removes the listener
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* onKeyPressed('a', type);
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function onKeyPressed(key: KeyFilter, handler: (event: KeyboardEvent) => void, options: Omit<OnKeyStrokeOptions, 'eventName'> = {}): VoidFunction {
|
||||||
|
return onKeyStroke(key, handler, { ...options, eventName: 'keypress' });
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { effectScope, isReadonly, nextTick } from 'vue';
|
||||||
|
import { useActiveElement } from '.';
|
||||||
|
|
||||||
|
describe(useActiveElement, () => {
|
||||||
|
it('tracks the focused element', async () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
document.body.appendChild(input);
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let active: ReturnType<typeof useActiveElement>;
|
||||||
|
scope.run(() => {
|
||||||
|
active = useActiveElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
input.focus();
|
||||||
|
input.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(active!.value).toBe(input);
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
input.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a shallow ref (not readonly)', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let active: ReturnType<typeof useActiveElement>;
|
||||||
|
scope.run(() => {
|
||||||
|
active = useActiveElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
// shallowRef is writable internally; ensure we did not return a readonly wrapper
|
||||||
|
expect(isReadonly(active!)).toBeFalsy();
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reflects the active element on creation', () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.focus();
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let active: ReturnType<typeof useActiveElement>;
|
||||||
|
scope.run(() => {
|
||||||
|
active = useActiveElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
// initial trigger should capture the already-focused element synchronously
|
||||||
|
expect(active!.value).toBe(input);
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
input.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('traverses open shadow roots when deep', async () => {
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const shadow = host.attachShadow({ mode: 'open' });
|
||||||
|
const inner = document.createElement('input');
|
||||||
|
shadow.appendChild(inner);
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let active: ReturnType<typeof useActiveElement>;
|
||||||
|
scope.run(() => {
|
||||||
|
active = useActiveElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
inner.focus();
|
||||||
|
document.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(active!.value).toBe(inner);
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
host.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not descend into shadow roots when deep is false', async () => {
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const shadow = host.attachShadow({ mode: 'open' });
|
||||||
|
const inner = document.createElement('input');
|
||||||
|
shadow.appendChild(inner);
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let active: ReturnType<typeof useActiveElement>;
|
||||||
|
scope.run(() => {
|
||||||
|
active = useActiveElement({ deep: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
inner.focus();
|
||||||
|
document.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
// with deep:false we stay at the shadow host instead of piercing it
|
||||||
|
expect(active!.value).toBe(host);
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
host.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets when focus leaves the window (blur with no relatedTarget)', async () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
document.body.appendChild(input);
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let active: ReturnType<typeof useActiveElement>;
|
||||||
|
scope.run(() => {
|
||||||
|
active = useActiveElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
input.focus();
|
||||||
|
globalThis.dispatchEvent(new FocusEvent('focus'));
|
||||||
|
await nextTick();
|
||||||
|
expect(active!.value).toBe(input);
|
||||||
|
|
||||||
|
// simulate focus leaving the document entirely
|
||||||
|
input.blur();
|
||||||
|
globalThis.dispatchEvent(new FocusEvent('blur', { relatedTarget: null }));
|
||||||
|
await nextTick();
|
||||||
|
expect(active!.value).toBe(document.body);
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
input.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores window blur when focus moves to another element (relatedTarget set)', async () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
document.body.appendChild(input);
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let active: ReturnType<typeof useActiveElement>;
|
||||||
|
scope.run(() => {
|
||||||
|
active = useActiveElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
input.focus();
|
||||||
|
await nextTick();
|
||||||
|
expect(active!.value).toBe(input);
|
||||||
|
|
||||||
|
// blur carrying a relatedTarget means focus stayed within the page -> ignore
|
||||||
|
const other = document.createElement('button');
|
||||||
|
globalThis.dispatchEvent(new FocusEvent('blur', { relatedTarget: other }));
|
||||||
|
await nextTick();
|
||||||
|
expect(active!.value).toBe(input);
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
input.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a custom document via options', () => {
|
||||||
|
const fakeEl = document.createElement('textarea');
|
||||||
|
const fakeDocument = { activeElement: fakeEl } as unknown as Document;
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let active: ReturnType<typeof useActiveElement>;
|
||||||
|
scope.run(() => {
|
||||||
|
active = useActiveElement({ document: fakeDocument });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(active!.value).toBe(fakeEl);
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw and stays undefined when document has no active element', () => {
|
||||||
|
// emulate an environment (e.g. SSR / detached document) with no focus
|
||||||
|
const emptyDocument = { activeElement: null } as unknown as Document;
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let active: ReturnType<typeof useActiveElement>;
|
||||||
|
scope.run(() => {
|
||||||
|
// pass a real window so listeners attach without error, but a doc with no focus
|
||||||
|
active = useActiveElement({ document: emptyDocument });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(active!.value).toBeNull();
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-evaluates when the active element is removed (triggerOnRemoval)', async () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
document.body.appendChild(input);
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let active: ReturnType<typeof useActiveElement>;
|
||||||
|
scope.run(() => {
|
||||||
|
active = useActiveElement({ triggerOnRemoval: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
input.focus();
|
||||||
|
await nextTick();
|
||||||
|
expect(active!.value).toBe(input);
|
||||||
|
|
||||||
|
input.remove();
|
||||||
|
// MutationObserver delivery is async; wait a microtask-ish tick
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(active!.value).toBe(document.body);
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { shallowRef } from 'vue';
|
||||||
|
import type { ShallowRef } from 'vue';
|
||||||
|
import { defaultWindow } from '@/types';
|
||||||
|
import type { ConfigurableDocument, ConfigurableWindow } from '@/types';
|
||||||
|
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||||
|
import { useMutationObserver } from '@/composables/browser/useMutationObserver';
|
||||||
|
|
||||||
|
export interface UseActiveElementOptions extends ConfigurableWindow, ConfigurableDocument {
|
||||||
|
/**
|
||||||
|
* Search for the active element inside open shadow roots
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
deep?: boolean;
|
||||||
|
/**
|
||||||
|
* Re-evaluate the active element when it is removed from the DOM.
|
||||||
|
* Uses a `MutationObserver` under the hood, so it is only enabled on demand.
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
triggerOnRemoval?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseActiveElementReturn<T extends HTMLElement = HTMLElement> = ShallowRef<T | null | undefined>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useActiveElement
|
||||||
|
* @category Browser
|
||||||
|
* @description Reactive `document.activeElement`, traversing open shadow roots.
|
||||||
|
*
|
||||||
|
* @param {UseActiveElementOptions} [options={}] Options
|
||||||
|
* @returns {UseActiveElementReturn<T>} The currently focused element
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const active = useActiveElement();
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // keep tracking even if the focused node is detached from the DOM
|
||||||
|
* const active = useActiveElement({ triggerOnRemoval: true });
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useActiveElement<T extends HTMLElement>(
|
||||||
|
options: UseActiveElementOptions = {},
|
||||||
|
): UseActiveElementReturn<T> {
|
||||||
|
const {
|
||||||
|
window = defaultWindow,
|
||||||
|
deep = true,
|
||||||
|
triggerOnRemoval = false,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const document = options.document ?? window?.document;
|
||||||
|
|
||||||
|
const getDeepActiveElement = (): Element | null | undefined => {
|
||||||
|
let element = document?.activeElement;
|
||||||
|
|
||||||
|
if (deep) {
|
||||||
|
while (element?.shadowRoot)
|
||||||
|
element = element.shadowRoot.activeElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return element;
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeElement = shallowRef<T | null | undefined>();
|
||||||
|
|
||||||
|
const trigger = (): void => {
|
||||||
|
activeElement.value = getDeepActiveElement() as T | null | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (window) {
|
||||||
|
const listenerOptions = { capture: true, passive: true } as const;
|
||||||
|
|
||||||
|
// `focus` (capture) catches focus moving onto any element, including those
|
||||||
|
// inside open shadow roots; `blur` with no `relatedTarget` resets the ref
|
||||||
|
// when focus leaves the document/window entirely.
|
||||||
|
useEventListener(
|
||||||
|
window,
|
||||||
|
'blur',
|
||||||
|
(event: FocusEvent) => {
|
||||||
|
if (event.relatedTarget !== null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
trigger();
|
||||||
|
},
|
||||||
|
listenerOptions,
|
||||||
|
);
|
||||||
|
useEventListener(window, 'focus', trigger, listenerOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (triggerOnRemoval && document) {
|
||||||
|
useMutationObserver(
|
||||||
|
() => [document.body],
|
||||||
|
(mutations) => {
|
||||||
|
for (const mutation of mutations) {
|
||||||
|
for (const removed of mutation.removedNodes) {
|
||||||
|
if (removed === activeElement.value || removed.contains(activeElement.value as Node)) {
|
||||||
|
trigger();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ window, childList: true, subtree: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger();
|
||||||
|
|
||||||
|
return activeElement;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { useBodyScrollLock } from '.';
|
||||||
|
|
||||||
|
describe(useBodyScrollLock, () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
document.body.style.paddingRight = '';
|
||||||
|
document.body.style.touchAction = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('locks body overflow', () => {
|
||||||
|
const release = useBodyScrollLock();
|
||||||
|
expect(document.body.style.overflow).toBe('hidden');
|
||||||
|
release();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores original overflow after release', () => {
|
||||||
|
document.body.style.overflow = 'auto';
|
||||||
|
const release = useBodyScrollLock();
|
||||||
|
expect(document.body.style.overflow).toBe('hidden');
|
||||||
|
release();
|
||||||
|
expect(document.body.style.overflow).toBe('auto');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reference-counts concurrent holders', () => {
|
||||||
|
const r1 = useBodyScrollLock();
|
||||||
|
const r2 = useBodyScrollLock();
|
||||||
|
expect(document.body.style.overflow).toBe('hidden');
|
||||||
|
|
||||||
|
r1();
|
||||||
|
expect(document.body.style.overflow).toBe('hidden');
|
||||||
|
|
||||||
|
r2();
|
||||||
|
expect(document.body.style.overflow).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('release is idempotent', () => {
|
||||||
|
const release = useBodyScrollLock();
|
||||||
|
release();
|
||||||
|
release();
|
||||||
|
expect(document.body.style.overflow).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||||
|
import { isClient } from '@robonen/platform/multi';
|
||||||
|
import type { VoidFunction } from '@robonen/stdlib';
|
||||||
|
import { noop } from '@robonen/stdlib';
|
||||||
|
|
||||||
|
interface LockState {
|
||||||
|
refs: number;
|
||||||
|
originalOverflow: string;
|
||||||
|
originalPaddingRight: string;
|
||||||
|
originalTouchAction: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let state: LockState | null = null;
|
||||||
|
|
||||||
|
function acquire(): VoidFunction {
|
||||||
|
if (!isClient) return noop;
|
||||||
|
|
||||||
|
if (!state) {
|
||||||
|
const { body, documentElement } = document;
|
||||||
|
const scrollbarWidth = globalThis.innerWidth - documentElement.clientWidth;
|
||||||
|
|
||||||
|
state = {
|
||||||
|
refs: 0,
|
||||||
|
originalOverflow: body.style.overflow,
|
||||||
|
originalPaddingRight: body.style.paddingRight,
|
||||||
|
originalTouchAction: body.style.touchAction,
|
||||||
|
};
|
||||||
|
|
||||||
|
body.style.overflow = 'hidden';
|
||||||
|
body.style.touchAction = 'none';
|
||||||
|
|
||||||
|
// Compensate scrollbar removal to prevent layout shift
|
||||||
|
if (scrollbarWidth > 0) {
|
||||||
|
const computedPr = Number.parseInt(globalThis.getComputedStyle(body).paddingRight, 10) || 0;
|
||||||
|
body.style.paddingRight = `${computedPr + scrollbarWidth}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.refs++;
|
||||||
|
|
||||||
|
let released = false;
|
||||||
|
const release = () => {
|
||||||
|
if (released || !state) return;
|
||||||
|
released = true;
|
||||||
|
state.refs--;
|
||||||
|
|
||||||
|
if (state.refs === 0) {
|
||||||
|
document.body.style.overflow = state.originalOverflow;
|
||||||
|
document.body.style.paddingRight = state.originalPaddingRight;
|
||||||
|
document.body.style.touchAction = state.originalTouchAction;
|
||||||
|
state = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tryOnScopeDispose(release);
|
||||||
|
return release;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useBodyScrollLock
|
||||||
|
* @category Browser
|
||||||
|
* @description Reference-counted body scroll lock. Safe to invoke from multiple
|
||||||
|
* concurrent modals — the lock releases only after all holders release. Preserves
|
||||||
|
* the original overflow/padding/touch-action values and compensates for scrollbar
|
||||||
|
* removal to prevent layout shift.
|
||||||
|
*
|
||||||
|
* @returns {VoidFunction} Release function. Idempotent — call once per acquire.
|
||||||
|
*
|
||||||
|
* @since 0.0.14
|
||||||
|
*/
|
||||||
|
export function useBodyScrollLock(): VoidFunction {
|
||||||
|
return acquire();
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { defineComponent, h, nextTick, ref } from 'vue';
|
||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import { useClickOutside } from '.';
|
||||||
|
|
||||||
|
function mountWithOutside(handler: (e: Event) => void) {
|
||||||
|
const Comp = defineComponent({
|
||||||
|
setup() {
|
||||||
|
const target = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
return () => h('div', {
|
||||||
|
ref: (el: any) => { target.value = el; },
|
||||||
|
'data-testid': 'target',
|
||||||
|
}, [
|
||||||
|
h('button', { 'data-testid': 'inside' }, 'inside'),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
useClickOutside(() => this.$el, handler);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return mount(Comp, { attachTo: document.body });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(useClickOutside, () => {
|
||||||
|
it('invokes handler on outside pointerdown', async () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const w = mountWithOutside(handler);
|
||||||
|
|
||||||
|
const outside = document.createElement('button');
|
||||||
|
document.body.appendChild(outside);
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
outside.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true }));
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
outside.remove();
|
||||||
|
w.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not invoke handler on inside pointerdown', async () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const w = mountWithOutside(handler);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const inside = w.find('[data-testid=inside]').element as HTMLElement;
|
||||||
|
inside.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true }));
|
||||||
|
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
w.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects the ignore list', async () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const ignored = document.createElement('div');
|
||||||
|
document.body.appendChild(ignored);
|
||||||
|
|
||||||
|
const Comp = defineComponent({
|
||||||
|
setup() {
|
||||||
|
return () => h('div', { 'data-testid': 'target' }, 'target');
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
useClickOutside(() => this.$el, handler, { ignore: [ignored] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const w = mount(Comp, { attachTo: document.body });
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
ignored.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true }));
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
ignored.remove();
|
||||||
|
w.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
||||||
|
import { unrefElement } from '@/composables/component/unrefElement';
|
||||||
|
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||||
|
import { defaultWindow } from '@/types';
|
||||||
|
import type { MaybeRefOrGetter } from 'vue';
|
||||||
|
import { toValue } from 'vue';
|
||||||
|
import type { VoidFunction } from '@robonen/stdlib';
|
||||||
|
import { noop } from '@robonen/stdlib';
|
||||||
|
|
||||||
|
export interface UseClickOutsideOptions {
|
||||||
|
/**
|
||||||
|
* Elements that are inside `target` semantically but physically rendered
|
||||||
|
* elsewhere (e.g. portaled menus). Events originating in these nodes
|
||||||
|
* are treated as *inside* clicks.
|
||||||
|
*/
|
||||||
|
ignore?: MaybeRefOrGetter<Array<MaybeComputedElementRef | undefined>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect outside pointer-down instead of click. Useful for dismissable layers
|
||||||
|
* that want to react as soon as the user starts interacting outside.
|
||||||
|
* @default 'pointerdown'
|
||||||
|
*/
|
||||||
|
event?: 'pointerdown' | 'mousedown' | 'click';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useClickOutside
|
||||||
|
* @category Browser
|
||||||
|
* @description Invokes `handler` when a pointer event occurs outside `target`.
|
||||||
|
* SSR-safe: no-op on the server. Handles portaled/ignored subtrees and
|
||||||
|
* guards against synthetic "outside" clicks on removed nodes.
|
||||||
|
*
|
||||||
|
* @param {MaybeComputedElementRef} target Element to watch. Events inside it are ignored.
|
||||||
|
* @param {(event: PointerEvent | MouseEvent) => void} handler Callback invoked with the outside event
|
||||||
|
* @param {UseClickOutsideOptions} [options] Options
|
||||||
|
* @returns {VoidFunction} Stop handle to remove the listeners
|
||||||
|
*
|
||||||
|
* @since 0.0.14
|
||||||
|
*/
|
||||||
|
export function useClickOutside(
|
||||||
|
target: MaybeComputedElementRef,
|
||||||
|
handler: (event: PointerEvent | MouseEvent) => void,
|
||||||
|
options: UseClickOutsideOptions = {},
|
||||||
|
): VoidFunction {
|
||||||
|
if (!defaultWindow) return noop;
|
||||||
|
|
||||||
|
const { event = 'pointerdown', ignore } = options;
|
||||||
|
|
||||||
|
const listener = (e: Event) => {
|
||||||
|
const el = unrefElement(target) as HTMLElement | undefined;
|
||||||
|
const pe = e as PointerEvent;
|
||||||
|
const path = (e.composedPath?.() ?? []) as Node[];
|
||||||
|
const eventTarget = (path[0] ?? e.target) as Node | null;
|
||||||
|
|
||||||
|
if (!el || !eventTarget) return;
|
||||||
|
if (el === eventTarget || el.contains(eventTarget)) return;
|
||||||
|
|
||||||
|
const ignoreList = toValue(ignore) ?? [];
|
||||||
|
for (const ref of ignoreList) {
|
||||||
|
const node = unrefElement(ref) as HTMLElement | undefined;
|
||||||
|
if (node && (node === eventTarget || node.contains(eventTarget))) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handler(pe);
|
||||||
|
};
|
||||||
|
|
||||||
|
return useEventListener(defaultWindow, event, listener, { passive: true, capture: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope, nextTick } from 'vue';
|
||||||
|
import type { UseClipboardReturn } from '.';
|
||||||
|
import { useClipboard } from '.';
|
||||||
|
|
||||||
|
function stubClipboard() {
|
||||||
|
const writeText = vi.fn(async () => {});
|
||||||
|
const readText = vi.fn(async () => 'pasted');
|
||||||
|
const navigator = {
|
||||||
|
clipboard: { writeText, readText },
|
||||||
|
} as unknown as Navigator;
|
||||||
|
return { navigator, writeText, readText };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(useClipboard, () => {
|
||||||
|
it('reports support when the clipboard API exists', () => {
|
||||||
|
const { navigator } = stubClipboard();
|
||||||
|
const scope = effectScope();
|
||||||
|
let clip: UseClipboardReturn<false>;
|
||||||
|
scope.run(() => {
|
||||||
|
clip = useClipboard({ navigator });
|
||||||
|
});
|
||||||
|
expect(clip!.isSupported.value).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not supported without the clipboard API', () => {
|
||||||
|
const navigator = {} as unknown as Navigator;
|
||||||
|
const scope = effectScope();
|
||||||
|
let clip: UseClipboardReturn<false>;
|
||||||
|
scope.run(() => {
|
||||||
|
clip = useClipboard({ navigator });
|
||||||
|
});
|
||||||
|
expect(clip!.isSupported.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies text and sets copied flag', async () => {
|
||||||
|
const { navigator, writeText } = stubClipboard();
|
||||||
|
const scope = effectScope();
|
||||||
|
let clip: UseClipboardReturn<false>;
|
||||||
|
scope.run(() => {
|
||||||
|
clip = useClipboard({ navigator });
|
||||||
|
});
|
||||||
|
|
||||||
|
await clip!.copy('hello');
|
||||||
|
expect(writeText).toHaveBeenCalledWith('hello');
|
||||||
|
expect(clip!.text.value).toBe('hello');
|
||||||
|
expect(clip!.copied.value).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies the configured source when called without args', async () => {
|
||||||
|
const { navigator, writeText } = stubClipboard();
|
||||||
|
const scope = effectScope();
|
||||||
|
let clip: any;
|
||||||
|
scope.run(() => {
|
||||||
|
clip = useClipboard({ navigator, source: 'from-source' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await clip.copy();
|
||||||
|
expect(writeText).toHaveBeenCalledWith('from-source');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies a value resolved from an async getter', async () => {
|
||||||
|
const { navigator, writeText } = stubClipboard();
|
||||||
|
const scope = effectScope();
|
||||||
|
let clip: UseClipboardReturn<false>;
|
||||||
|
scope.run(() => {
|
||||||
|
clip = useClipboard({ navigator });
|
||||||
|
});
|
||||||
|
|
||||||
|
await clip!.copy(async () => 'lazy');
|
||||||
|
expect(writeText).toHaveBeenCalledWith('lazy');
|
||||||
|
expect(clip!.text.value).toBe('lazy');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips when an async getter resolves to null', async () => {
|
||||||
|
const { navigator, writeText } = stubClipboard();
|
||||||
|
const scope = effectScope();
|
||||||
|
let clip: UseClipboardReturn<false>;
|
||||||
|
scope.run(() => {
|
||||||
|
clip = useClipboard({ navigator });
|
||||||
|
});
|
||||||
|
|
||||||
|
await clip!.copy(async () => undefined);
|
||||||
|
expect(writeText).not.toHaveBeenCalled();
|
||||||
|
expect(clip!.copied.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes copyPending around an in-flight async copy', async () => {
|
||||||
|
const { navigator } = stubClipboard();
|
||||||
|
const scope = effectScope();
|
||||||
|
let clip: UseClipboardReturn<false>;
|
||||||
|
scope.run(() => {
|
||||||
|
clip = useClipboard({ navigator });
|
||||||
|
});
|
||||||
|
|
||||||
|
let release: (v: string) => void = () => {};
|
||||||
|
const promise = clip!.copy(() => new Promise<string>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
}));
|
||||||
|
expect(clip!.copyPending.value).toBeTruthy();
|
||||||
|
release('done');
|
||||||
|
await promise;
|
||||||
|
expect(clip!.copyPending.value).toBeFalsy();
|
||||||
|
expect(clip!.text.value).toBe('done');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a stale async copy superseded by a newer one', async () => {
|
||||||
|
const { navigator, writeText } = stubClipboard();
|
||||||
|
const scope = effectScope();
|
||||||
|
let clip: UseClipboardReturn<false>;
|
||||||
|
scope.run(() => {
|
||||||
|
clip = useClipboard({ navigator });
|
||||||
|
});
|
||||||
|
|
||||||
|
let releaseSlow: (v: string) => void = () => {};
|
||||||
|
const slow = clip!.copy(() => new Promise<string>((resolve) => {
|
||||||
|
releaseSlow = resolve;
|
||||||
|
}));
|
||||||
|
const fast = clip!.copy(async () => 'fast');
|
||||||
|
await fast;
|
||||||
|
releaseSlow('slow');
|
||||||
|
await slow;
|
||||||
|
|
||||||
|
expect(clip!.text.value).toBe('fast');
|
||||||
|
expect(writeText).toHaveBeenCalledTimes(1);
|
||||||
|
expect(writeText).toHaveBeenCalledWith('fast');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when unsupported', async () => {
|
||||||
|
const navigator = {} as unknown as Navigator;
|
||||||
|
const scope = effectScope();
|
||||||
|
let clip: UseClipboardReturn<false>;
|
||||||
|
scope.run(() => {
|
||||||
|
clip = useClipboard({ navigator });
|
||||||
|
});
|
||||||
|
|
||||||
|
await clip!.copy('x');
|
||||||
|
expect(clip!.copied.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('syncs text on copy/cut events when read is enabled', async () => {
|
||||||
|
const { navigator, readText } = stubClipboard();
|
||||||
|
const scope = effectScope();
|
||||||
|
let clip: UseClipboardReturn<false>;
|
||||||
|
scope.run(() => {
|
||||||
|
clip = useClipboard({ navigator, read: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
globalThis.dispatchEvent(new Event('copy'));
|
||||||
|
await nextTick();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(readText).toHaveBeenCalled();
|
||||||
|
expect(clip!.text.value).toBe('pasted');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { shallowReadonly, shallowRef, toValue } from 'vue';
|
||||||
|
import type { MaybeRefOrGetter, Ref } from 'vue';
|
||||||
|
import { isString } from '@robonen/stdlib';
|
||||||
|
import { defaultNavigator } from '@/types';
|
||||||
|
import type { ConfigurableNavigator } from '@/types';
|
||||||
|
import { useSupported } from '@/composables/browser/useSupported';
|
||||||
|
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||||
|
import { useTimeoutFn } from '@/composables/utilities/useTimeoutFn';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A value to copy: either a string or an (optionally async) getter that resolves to one.
|
||||||
|
*/
|
||||||
|
export type ClipboardValue = string | (() => Promise<string | undefined> | string | undefined);
|
||||||
|
|
||||||
|
export interface UseClipboardOptions<Source> extends ConfigurableNavigator {
|
||||||
|
/**
|
||||||
|
* Sync `text` with the system clipboard by listening to copy/cut events
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
read?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default source value to copy when `copy()` is called without an argument
|
||||||
|
*/
|
||||||
|
source?: Source;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Milliseconds the `copied` flag stays `true` after a copy
|
||||||
|
*
|
||||||
|
* @default 1500
|
||||||
|
*/
|
||||||
|
copiedDuring?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseClipboardReturn<Optional extends boolean> {
|
||||||
|
/**
|
||||||
|
* Whether the async Clipboard API is available
|
||||||
|
*/
|
||||||
|
isSupported: Readonly<Ref<boolean>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current clipboard text (kept in sync when `read` is enabled)
|
||||||
|
*/
|
||||||
|
text: Readonly<Ref<string>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `true` for `copiedDuring` ms after a successful copy
|
||||||
|
*/
|
||||||
|
copied: Readonly<Ref<boolean>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `true` while an async `copy()` is in flight
|
||||||
|
*/
|
||||||
|
copyPending: Readonly<Ref<boolean>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy a value to the clipboard
|
||||||
|
*/
|
||||||
|
copy: Optional extends true ? (text?: ClipboardValue) => Promise<void> : (text: ClipboardValue) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useClipboard
|
||||||
|
* @category Browser
|
||||||
|
* @description Reactive async Clipboard API.
|
||||||
|
*
|
||||||
|
* @param {UseClipboardOptions} [options={}] Options
|
||||||
|
* @returns {UseClipboardReturn} `isSupported`, `text`, `copied`, `copyPending`, and `copy`
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { text, copy, copied, isSupported } = useClipboard();
|
||||||
|
* copy('hello');
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Copy a lazily/asynchronously resolved value
|
||||||
|
* copy(async () => (await fetch('/token').then(r => r.text())));
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useClipboard(options?: UseClipboardOptions<undefined>): UseClipboardReturn<false>;
|
||||||
|
export function useClipboard(options: UseClipboardOptions<MaybeRefOrGetter<string>>): UseClipboardReturn<true>;
|
||||||
|
export function useClipboard(
|
||||||
|
options: UseClipboardOptions<MaybeRefOrGetter<string> | undefined> = {},
|
||||||
|
): UseClipboardReturn<boolean> {
|
||||||
|
const {
|
||||||
|
navigator = defaultNavigator,
|
||||||
|
read = false,
|
||||||
|
source,
|
||||||
|
copiedDuring = 1500,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const isSupported = useSupported(() => navigator && 'clipboard' in navigator);
|
||||||
|
|
||||||
|
const text = shallowRef('');
|
||||||
|
const copied = shallowRef(false);
|
||||||
|
const copyPending = shallowRef(false);
|
||||||
|
|
||||||
|
// Guards against a slow async copy clobbering the result of a newer one
|
||||||
|
let lastResolveId = 0;
|
||||||
|
|
||||||
|
const timeout = useTimeoutFn(() => {
|
||||||
|
copied.value = false;
|
||||||
|
}, copiedDuring, { immediate: false });
|
||||||
|
|
||||||
|
async function updateText(): Promise<void> {
|
||||||
|
text.value = await navigator!.clipboard.readText();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSupported.value && read)
|
||||||
|
useEventListener(['copy', 'cut'], updateText, { passive: true });
|
||||||
|
|
||||||
|
async function copy(value: ClipboardValue | undefined = toValue(source)): Promise<void> {
|
||||||
|
if (!isSupported.value || value === null || value === undefined)
|
||||||
|
return;
|
||||||
|
|
||||||
|
copyPending.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let resolved: string | undefined;
|
||||||
|
|
||||||
|
if (isString(value)) {
|
||||||
|
resolved = value;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const currentId = ++lastResolveId;
|
||||||
|
resolved = await value();
|
||||||
|
|
||||||
|
// Drop a stale async resolution superseded by a newer copy
|
||||||
|
if (resolved === null || resolved === undefined || currentId !== lastResolveId)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await navigator!.clipboard.writeText(resolved);
|
||||||
|
|
||||||
|
text.value = resolved;
|
||||||
|
copied.value = true;
|
||||||
|
timeout.start();
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
copyPending.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSupported,
|
||||||
|
text: shallowReadonly(text),
|
||||||
|
copied: shallowReadonly(copied),
|
||||||
|
copyPending: shallowReadonly(copyPending),
|
||||||
|
copy,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope } from 'vue';
|
||||||
|
import { useCloseWatcher } from '.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal fake of the native `CloseWatcher`: tracks instances so tests can drive
|
||||||
|
* close/destroy and assert recreation behaviour.
|
||||||
|
*/
|
||||||
|
function createCloseWatcherStub() {
|
||||||
|
const instances: FakeCloseWatcher[] = [];
|
||||||
|
|
||||||
|
class FakeCloseWatcher {
|
||||||
|
listeners = new Map<string, Set<(event: Event) => void>>();
|
||||||
|
destroyed = false;
|
||||||
|
requestCloseCalls = 0;
|
||||||
|
closeCalls = 0;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
instances.push(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener(type: string, listener: (event: Event) => void) {
|
||||||
|
if (!this.listeners.has(type))
|
||||||
|
this.listeners.set(type, new Set());
|
||||||
|
this.listeners.get(type)!.add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEventListener(type: string, listener: (event: Event) => void) {
|
||||||
|
this.listeners.get(type)?.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestClose() {
|
||||||
|
this.requestCloseCalls++;
|
||||||
|
this.fireClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.closeCalls++;
|
||||||
|
this.fireClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.destroyed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private fireClose() {
|
||||||
|
const event = new Event('close');
|
||||||
|
this.listeners.get('close')?.forEach(fn => fn(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
oncancel: ((event: Event) => void) | null = null;
|
||||||
|
onclose: ((event: Event) => void) | null = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A window-like object that exposes CloseWatcher and basic event listening
|
||||||
|
const eventTarget = new EventTarget();
|
||||||
|
const win = {
|
||||||
|
CloseWatcher: FakeCloseWatcher,
|
||||||
|
addEventListener: eventTarget.addEventListener.bind(eventTarget),
|
||||||
|
removeEventListener: eventTarget.removeEventListener.bind(eventTarget),
|
||||||
|
dispatchEvent: eventTarget.dispatchEvent.bind(eventTarget),
|
||||||
|
} as unknown as Window;
|
||||||
|
|
||||||
|
return { win, instances };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A window-like object WITHOUT CloseWatcher (fallback path). */
|
||||||
|
function createFallbackWindow() {
|
||||||
|
const eventTarget = new EventTarget();
|
||||||
|
const win = {
|
||||||
|
addEventListener: eventTarget.addEventListener.bind(eventTarget),
|
||||||
|
removeEventListener: eventTarget.removeEventListener.bind(eventTarget),
|
||||||
|
dispatchEvent: eventTarget.dispatchEvent.bind(eventTarget),
|
||||||
|
} as unknown as Window;
|
||||||
|
|
||||||
|
const dispatchKey = (key: string) =>
|
||||||
|
win.dispatchEvent(new KeyboardEvent('keydown', { key }));
|
||||||
|
|
||||||
|
return { win, dispatchKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(useCloseWatcher, () => {
|
||||||
|
it('reports support when CloseWatcher exists on window', () => {
|
||||||
|
const { win } = createCloseWatcherStub();
|
||||||
|
const scope = effectScope();
|
||||||
|
let cw: ReturnType<typeof useCloseWatcher>;
|
||||||
|
scope.run(() => {
|
||||||
|
cw = useCloseWatcher({ window: win });
|
||||||
|
});
|
||||||
|
expect(cw!.isSupported.value).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports unsupported when CloseWatcher is absent', () => {
|
||||||
|
const { win } = createFallbackWindow();
|
||||||
|
const scope = effectScope();
|
||||||
|
let cw: ReturnType<typeof useCloseWatcher>;
|
||||||
|
scope.run(() => {
|
||||||
|
cw = useCloseWatcher({ window: win });
|
||||||
|
});
|
||||||
|
expect(cw!.isSupported.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a safe no-op when there is no window (SSR)', () => {
|
||||||
|
// Force the SSR branch with an explicit falsy (non-undefined) window so the
|
||||||
|
// default-parameter fallback to `defaultWindow` does not kick in: only
|
||||||
|
// `undefined` triggers a parameter default, `null` survives the destructure.
|
||||||
|
const scope = effectScope();
|
||||||
|
let cw: ReturnType<typeof useCloseWatcher>;
|
||||||
|
scope.run(() => {
|
||||||
|
cw = useCloseWatcher({ window: null as unknown as Window });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(cw!.isSupported.value).toBeFalsy();
|
||||||
|
|
||||||
|
const handler = vi.fn();
|
||||||
|
const stop = cw!.onClose(handler);
|
||||||
|
expect(() => cw!.close()).not.toThrow();
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
expect(() => stop()).not.toThrow();
|
||||||
|
expect(() => cw!.destroy()).not.toThrow();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('native CloseWatcher path', () => {
|
||||||
|
it('fires registered handler when close() is requested', () => {
|
||||||
|
const { win, instances } = createCloseWatcherStub();
|
||||||
|
const scope = effectScope();
|
||||||
|
let cw: ReturnType<typeof useCloseWatcher>;
|
||||||
|
scope.run(() => {
|
||||||
|
cw = useCloseWatcher({ window: win });
|
||||||
|
});
|
||||||
|
|
||||||
|
const handler = vi.fn();
|
||||||
|
cw!.onClose(handler);
|
||||||
|
expect(instances).toHaveLength(1);
|
||||||
|
|
||||||
|
cw!.close();
|
||||||
|
expect(instances[0]!.requestCloseCalls).toBe(1);
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
expect(handler.mock.calls[0]![0]).toBeInstanceOf(Event);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires handler when the native close event occurs (Esc / back)', () => {
|
||||||
|
const { win, instances } = createCloseWatcherStub();
|
||||||
|
const scope = effectScope();
|
||||||
|
let cw: ReturnType<typeof useCloseWatcher>;
|
||||||
|
scope.run(() => {
|
||||||
|
cw = useCloseWatcher({ window: win });
|
||||||
|
});
|
||||||
|
|
||||||
|
const handler = vi.fn();
|
||||||
|
cw!.onClose(handler);
|
||||||
|
|
||||||
|
// Simulate the platform firing the close event (e.g. Esc / Android back)
|
||||||
|
instances[0]!.close();
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recreates the watcher after a close so it keeps working', () => {
|
||||||
|
const { win, instances } = createCloseWatcherStub();
|
||||||
|
const scope = effectScope();
|
||||||
|
let cw: ReturnType<typeof useCloseWatcher>;
|
||||||
|
scope.run(() => {
|
||||||
|
cw = useCloseWatcher({ window: win });
|
||||||
|
});
|
||||||
|
|
||||||
|
const handler = vi.fn();
|
||||||
|
cw!.onClose(handler);
|
||||||
|
expect(instances).toHaveLength(1);
|
||||||
|
|
||||||
|
cw!.close();
|
||||||
|
// a fresh watcher is created after the close fired
|
||||||
|
expect(instances).toHaveLength(2);
|
||||||
|
|
||||||
|
cw!.close();
|
||||||
|
expect(handler).toHaveBeenCalledTimes(2);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires all registered handlers with a single watcher', () => {
|
||||||
|
const { win, instances } = createCloseWatcherStub();
|
||||||
|
const scope = effectScope();
|
||||||
|
let cw: ReturnType<typeof useCloseWatcher>;
|
||||||
|
scope.run(() => {
|
||||||
|
cw = useCloseWatcher({ window: win });
|
||||||
|
});
|
||||||
|
|
||||||
|
const a = vi.fn();
|
||||||
|
const b = vi.fn();
|
||||||
|
cw!.onClose(a);
|
||||||
|
cw!.onClose(b);
|
||||||
|
// both handlers share one native watcher
|
||||||
|
expect(instances).toHaveLength(1);
|
||||||
|
|
||||||
|
cw!.close();
|
||||||
|
expect(a).toHaveBeenCalledTimes(1);
|
||||||
|
expect(b).toHaveBeenCalledTimes(1);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stop handle removes only its own handler', () => {
|
||||||
|
const { win } = createCloseWatcherStub();
|
||||||
|
const scope = effectScope();
|
||||||
|
let cw: ReturnType<typeof useCloseWatcher>;
|
||||||
|
scope.run(() => {
|
||||||
|
cw = useCloseWatcher({ window: win });
|
||||||
|
});
|
||||||
|
|
||||||
|
const a = vi.fn();
|
||||||
|
const b = vi.fn();
|
||||||
|
const stopA = cw!.onClose(a);
|
||||||
|
cw!.onClose(b);
|
||||||
|
|
||||||
|
stopA();
|
||||||
|
cw!.close();
|
||||||
|
expect(a).not.toHaveBeenCalled();
|
||||||
|
expect(b).toHaveBeenCalledTimes(1);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('destroy() tears down the watcher and clears handlers', () => {
|
||||||
|
const { win, instances } = createCloseWatcherStub();
|
||||||
|
const scope = effectScope();
|
||||||
|
let cw: ReturnType<typeof useCloseWatcher>;
|
||||||
|
scope.run(() => {
|
||||||
|
cw = useCloseWatcher({ window: win });
|
||||||
|
});
|
||||||
|
|
||||||
|
const handler = vi.fn();
|
||||||
|
cw!.onClose(handler);
|
||||||
|
cw!.destroy();
|
||||||
|
|
||||||
|
expect(instances[0]!.destroyed).toBeTruthy();
|
||||||
|
cw!.close();
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives a handler calling destroy() during dispatch', () => {
|
||||||
|
const { win } = createCloseWatcherStub();
|
||||||
|
const scope = effectScope();
|
||||||
|
let cw: ReturnType<typeof useCloseWatcher>;
|
||||||
|
scope.run(() => {
|
||||||
|
cw = useCloseWatcher({ window: win });
|
||||||
|
});
|
||||||
|
|
||||||
|
const other = vi.fn();
|
||||||
|
cw!.onClose(() => cw!.destroy());
|
||||||
|
cw!.onClose(other);
|
||||||
|
|
||||||
|
// dispatch must not throw even though destroy() clears the set mid-loop
|
||||||
|
expect(() => cw!.close()).not.toThrow();
|
||||||
|
// the snapshot means the second handler still runs for this dispatch
|
||||||
|
expect(other).toHaveBeenCalledTimes(1);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fallback (keydown) path', () => {
|
||||||
|
it('fires handler on Escape keydown', () => {
|
||||||
|
const { win, dispatchKey } = createFallbackWindow();
|
||||||
|
const scope = effectScope();
|
||||||
|
let cw: ReturnType<typeof useCloseWatcher>;
|
||||||
|
scope.run(() => {
|
||||||
|
cw = useCloseWatcher({ window: win });
|
||||||
|
});
|
||||||
|
|
||||||
|
const handler = vi.fn();
|
||||||
|
cw!.onClose(handler);
|
||||||
|
|
||||||
|
dispatchKey('Escape');
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores non-Escape keys', () => {
|
||||||
|
const { win, dispatchKey } = createFallbackWindow();
|
||||||
|
const scope = effectScope();
|
||||||
|
let cw: ReturnType<typeof useCloseWatcher>;
|
||||||
|
scope.run(() => {
|
||||||
|
cw = useCloseWatcher({ window: win });
|
||||||
|
});
|
||||||
|
|
||||||
|
const handler = vi.fn();
|
||||||
|
cw!.onClose(handler);
|
||||||
|
|
||||||
|
dispatchKey('Enter');
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('close() synthesizes a close event in the fallback path', () => {
|
||||||
|
const { win } = createFallbackWindow();
|
||||||
|
const scope = effectScope();
|
||||||
|
let cw: ReturnType<typeof useCloseWatcher>;
|
||||||
|
scope.run(() => {
|
||||||
|
cw = useCloseWatcher({ window: win });
|
||||||
|
});
|
||||||
|
|
||||||
|
const handler = vi.fn();
|
||||||
|
cw!.onClose(handler);
|
||||||
|
|
||||||
|
cw!.close();
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('destroy() removes the keydown listener', () => {
|
||||||
|
const { win, dispatchKey } = createFallbackWindow();
|
||||||
|
const scope = effectScope();
|
||||||
|
let cw: ReturnType<typeof useCloseWatcher>;
|
||||||
|
scope.run(() => {
|
||||||
|
cw = useCloseWatcher({ window: win });
|
||||||
|
});
|
||||||
|
|
||||||
|
const handler = vi.fn();
|
||||||
|
cw!.onClose(handler);
|
||||||
|
cw!.destroy();
|
||||||
|
|
||||||
|
dispatchKey('Escape');
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disposes when the effect scope stops', () => {
|
||||||
|
const { win, instances } = createCloseWatcherStub();
|
||||||
|
const scope = effectScope();
|
||||||
|
let cw: ReturnType<typeof useCloseWatcher>;
|
||||||
|
scope.run(() => {
|
||||||
|
cw = useCloseWatcher({ window: win });
|
||||||
|
});
|
||||||
|
|
||||||
|
cw!.onClose(vi.fn());
|
||||||
|
expect(instances[0]!.destroyed).toBeFalsy();
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
expect(instances[0]!.destroyed).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import type { VoidFunction } from '@robonen/stdlib';
|
||||||
|
import { noop } from '@robonen/stdlib';
|
||||||
|
import type { Ref } from 'vue';
|
||||||
|
import { defaultWindow } from '@/types';
|
||||||
|
import type { ConfigurableWindow } from '@/types';
|
||||||
|
import { useSupported } from '@/composables/browser/useSupported';
|
||||||
|
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||||
|
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subset of the native `CloseWatcher` instance surface we rely on.
|
||||||
|
*
|
||||||
|
* @see https://developer.mozilla.org/en-US/docs/Web/API/CloseWatcher
|
||||||
|
*/
|
||||||
|
interface CloseWatcherInstance {
|
||||||
|
requestClose: () => void;
|
||||||
|
close: () => void;
|
||||||
|
destroy: () => void;
|
||||||
|
addEventListener: (type: string, listener: (event: Event) => void) => void;
|
||||||
|
removeEventListener: (type: string, listener: (event: Event) => void) => void;
|
||||||
|
oncancel: ((event: Event) => void) | null;
|
||||||
|
onclose: ((event: Event) => void) | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CloseWatcherConstructor = new (options?: { signal?: AbortSignal }) => CloseWatcherInstance;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler invoked when a close request is received.
|
||||||
|
*
|
||||||
|
* The argument is the native `close` event when the platform `CloseWatcher`
|
||||||
|
* is used, or the `Escape` `KeyboardEvent` when falling back to keydown.
|
||||||
|
*/
|
||||||
|
export type CloseWatcherHandler = (event: Event) => void;
|
||||||
|
|
||||||
|
export interface UseCloseWatcherOptions extends ConfigurableWindow {}
|
||||||
|
|
||||||
|
export interface UseCloseWatcherReturn {
|
||||||
|
/**
|
||||||
|
* Whether the native `CloseWatcher` API is available.
|
||||||
|
*/
|
||||||
|
isSupported: Readonly<Ref<boolean>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a handler for close requests (Esc key / Android back / `close()`).
|
||||||
|
*
|
||||||
|
* @returns A stop handle that removes this handler.
|
||||||
|
*/
|
||||||
|
onClose: (handler: CloseWatcherHandler) => VoidFunction;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request a close, firing every registered handler.
|
||||||
|
*/
|
||||||
|
close: VoidFunction;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tear down the watcher and remove all registered handlers.
|
||||||
|
*/
|
||||||
|
destroy: VoidFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useCloseWatcher
|
||||||
|
* @category Browser
|
||||||
|
* @description Wrap the native `CloseWatcher` API to handle close requests
|
||||||
|
* (the `Esc` key or the Android back gesture). Falls back to listening for
|
||||||
|
* `Escape` keydown when `CloseWatcher` is unavailable. SSR-safe.
|
||||||
|
*
|
||||||
|
* @param {UseCloseWatcherOptions} [options={}] Configuration options
|
||||||
|
* @returns {UseCloseWatcherReturn} `isSupported`, `onClose`, `close`, and `destroy`
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { onClose, close, isSupported } = useCloseWatcher();
|
||||||
|
* onClose(() => { dialogOpen.value = false; });
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Programmatically request a close
|
||||||
|
* close();
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useCloseWatcher(options: UseCloseWatcherOptions = {}): UseCloseWatcherReturn {
|
||||||
|
const { window = defaultWindow } = options;
|
||||||
|
|
||||||
|
const isSupported = useSupported(() => !!window && 'CloseWatcher' in window);
|
||||||
|
|
||||||
|
const handlers = new Set<CloseWatcherHandler>();
|
||||||
|
let watcher: CloseWatcherInstance | undefined;
|
||||||
|
let stopFallback: VoidFunction = noop;
|
||||||
|
|
||||||
|
const dispatch = (event: Event): void => {
|
||||||
|
// Snapshot so a handler that calls destroy()/onClose() can't mutate mid-loop
|
||||||
|
// eslint-disable-next-line unicorn/no-useless-spread
|
||||||
|
for (const handler of [...handlers])
|
||||||
|
handler(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const teardownWatcher = (): void => {
|
||||||
|
watcher?.destroy();
|
||||||
|
watcher = undefined;
|
||||||
|
stopFallback();
|
||||||
|
stopFallback = noop;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureWatcher = (): void => {
|
||||||
|
if (!window)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (isSupported.value) {
|
||||||
|
if (watcher)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const CloseWatcherCtor = (window as unknown as { CloseWatcher: CloseWatcherConstructor }).CloseWatcher;
|
||||||
|
watcher = new CloseWatcherCtor();
|
||||||
|
// The native watcher deactivates after a single close; recreate it so the
|
||||||
|
// returned `close()`/Esc keep working across multiple close requests.
|
||||||
|
watcher.addEventListener('close', (event: Event) => {
|
||||||
|
watcher = undefined;
|
||||||
|
dispatch(event);
|
||||||
|
ensureWatcher();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: only one keydown listener regardless of handler count
|
||||||
|
if (stopFallback !== noop)
|
||||||
|
return;
|
||||||
|
|
||||||
|
stopFallback = useEventListener(
|
||||||
|
window,
|
||||||
|
'keydown',
|
||||||
|
(event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape')
|
||||||
|
dispatch(event);
|
||||||
|
},
|
||||||
|
{ passive: true },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClose = (handler: CloseWatcherHandler): VoidFunction => {
|
||||||
|
handlers.add(handler);
|
||||||
|
ensureWatcher();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
handlers.delete(handler);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = (): void => {
|
||||||
|
if (!window)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (watcher) {
|
||||||
|
watcher.requestClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No active native watcher (unsupported, torn down, or none registered yet):
|
||||||
|
// synthesize a close event so handlers still fire.
|
||||||
|
dispatch(new Event('close'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const destroy = (): void => {
|
||||||
|
handlers.clear();
|
||||||
|
teardownWatcher();
|
||||||
|
};
|
||||||
|
|
||||||
|
tryOnScopeDispose(destroy);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSupported,
|
||||||
|
onClose,
|
||||||
|
close,
|
||||||
|
destroy,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope, nextTick, ref } from 'vue';
|
||||||
|
import { useColorMode } from '.';
|
||||||
|
|
||||||
|
type Listener = (event: { matches: boolean }) => void;
|
||||||
|
|
||||||
|
interface StubMql {
|
||||||
|
readonly matches: boolean;
|
||||||
|
media: string;
|
||||||
|
addEventListener: (type: string, cb: Listener) => void;
|
||||||
|
removeEventListener: (type: string, cb: Listener) => void;
|
||||||
|
dispatch: (value: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMql(initialMatches: boolean, media = ''): StubMql {
|
||||||
|
const listeners = new Set<Listener>();
|
||||||
|
let matches = initialMatches;
|
||||||
|
|
||||||
|
return {
|
||||||
|
get matches() {
|
||||||
|
return matches;
|
||||||
|
},
|
||||||
|
media,
|
||||||
|
addEventListener: (_: string, cb: Listener) => listeners.add(cb),
|
||||||
|
removeEventListener: (_: string, cb: Listener) => listeners.delete(cb),
|
||||||
|
dispatch(value: boolean) {
|
||||||
|
matches = value;
|
||||||
|
for (const cb of listeners) cb({ matches: value });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a stub `window` that reuses the real jsdom `document` (so DOM updates
|
||||||
|
* applied to `<html>` are observable) but with a controllable `matchMedia` for
|
||||||
|
* `prefers-color-scheme: dark`, an isolated in-memory `localStorage`, and a
|
||||||
|
* `getComputedStyle` shim for the transition-disabling reflow.
|
||||||
|
*/
|
||||||
|
function makeWindow(prefersDark: StubMql) {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
|
||||||
|
const storage: Storage = {
|
||||||
|
getItem: (key: string) => (map.has(key) ? map.get(key)! : null),
|
||||||
|
setItem: (key: string, value: string) => { map.set(key, String(value)); },
|
||||||
|
removeItem: (key: string) => { map.delete(key); },
|
||||||
|
clear: () => map.clear(),
|
||||||
|
key: (index: number) => [...map.keys()][index] ?? null,
|
||||||
|
get length() {
|
||||||
|
return map.size;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const win = {
|
||||||
|
document: globalThis.document,
|
||||||
|
matchMedia: vi.fn((query: string) =>
|
||||||
|
query.includes('dark') ? prefersDark : makeMql(false, query)),
|
||||||
|
localStorage: storage,
|
||||||
|
getComputedStyle: () => ({ opacity: '1' }),
|
||||||
|
dispatchEvent: () => true,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
} as unknown as Window & typeof globalThis;
|
||||||
|
|
||||||
|
return { win, storage, map };
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
document.documentElement.className = '';
|
||||||
|
document.documentElement.removeAttribute('data-theme');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(useColorMode, () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
reset();
|
||||||
|
// Ensure module-captured defaultWindow.matchMedia is undefined so the
|
||||||
|
// composable must use the injected window.
|
||||||
|
vi.stubGlobal('matchMedia', undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the system class when in auto mode (dark)', async () => {
|
||||||
|
const prefersDark = makeMql(true);
|
||||||
|
const { win } = makeWindow(prefersDark);
|
||||||
|
const scope = effectScope();
|
||||||
|
let mode: ReturnType<typeof useColorMode>;
|
||||||
|
|
||||||
|
scope.run(() => {
|
||||||
|
mode = useColorMode({ window: win });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(mode!.value).toBe('dark');
|
||||||
|
expect(mode!.system.value).toBe('dark');
|
||||||
|
expect(mode!.state.value).toBe('dark');
|
||||||
|
expect(document.documentElement.classList.contains('dark')).toBeTruthy();
|
||||||
|
expect(document.documentElement.classList.contains('light')).toBeFalsy();
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the system class when in auto mode (light)', async () => {
|
||||||
|
const prefersDark = makeMql(false);
|
||||||
|
const { win } = makeWindow(prefersDark);
|
||||||
|
const scope = effectScope();
|
||||||
|
let mode: ReturnType<typeof useColorMode>;
|
||||||
|
|
||||||
|
scope.run(() => {
|
||||||
|
mode = useColorMode({ window: win });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(mode!.system.value).toBe('light');
|
||||||
|
expect(mode!.state.value).toBe('light');
|
||||||
|
expect(document.documentElement.classList.contains('light')).toBeTruthy();
|
||||||
|
expect(document.documentElement.classList.contains('dark')).toBeFalsy();
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writing the ref switches the applied class and removes the previous one', async () => {
|
||||||
|
const prefersDark = makeMql(false);
|
||||||
|
const { win } = makeWindow(prefersDark);
|
||||||
|
const scope = effectScope();
|
||||||
|
let mode: ReturnType<typeof useColorMode>;
|
||||||
|
|
||||||
|
scope.run(() => {
|
||||||
|
mode = useColorMode({ window: win });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(document.documentElement.classList.contains('light')).toBeTruthy();
|
||||||
|
|
||||||
|
mode!.value = 'dark';
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(document.documentElement.classList.contains('dark')).toBeTruthy();
|
||||||
|
expect(document.documentElement.classList.contains('light')).toBeFalsy();
|
||||||
|
expect(mode!.value).toBe('dark');
|
||||||
|
expect(mode!.store.value).toBe('dark');
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reacts to system preference changes while in auto mode', async () => {
|
||||||
|
const prefersDark = makeMql(false);
|
||||||
|
const { win } = makeWindow(prefersDark);
|
||||||
|
const scope = effectScope();
|
||||||
|
let mode: ReturnType<typeof useColorMode>;
|
||||||
|
|
||||||
|
scope.run(() => {
|
||||||
|
mode = useColorMode({ window: win });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(mode!.state.value).toBe('light');
|
||||||
|
|
||||||
|
prefersDark.dispatch(true);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(mode!.system.value).toBe('dark');
|
||||||
|
expect(mode!.state.value).toBe('dark');
|
||||||
|
expect(document.documentElement.classList.contains('dark')).toBeTruthy();
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists the value to storage and reads it back', async () => {
|
||||||
|
const prefersDark = makeMql(false);
|
||||||
|
const { win, storage } = makeWindow(prefersDark);
|
||||||
|
const scope = effectScope();
|
||||||
|
let mode: ReturnType<typeof useColorMode>;
|
||||||
|
|
||||||
|
scope.run(() => {
|
||||||
|
mode = useColorMode({ window: win });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
mode!.value = 'dark';
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(storage.getItem('vuetools-color-scheme')).toBe('dark');
|
||||||
|
|
||||||
|
// A second instance backed by the same storage should hydrate from it.
|
||||||
|
let restored: ReturnType<typeof useColorMode>;
|
||||||
|
scope.run(() => {
|
||||||
|
restored = useColorMode({ window: win });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(restored!.value).toBe('dark');
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours a custom storageKey', async () => {
|
||||||
|
const prefersDark = makeMql(false);
|
||||||
|
const { win, storage } = makeWindow(prefersDark);
|
||||||
|
const scope = effectScope();
|
||||||
|
let mode: ReturnType<typeof useColorMode>;
|
||||||
|
|
||||||
|
scope.run(() => {
|
||||||
|
mode = useColorMode({ window: win, storageKey: 'my-theme' });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
mode!.value = 'dark';
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(storage.getItem('my-theme')).toBe('dark');
|
||||||
|
expect(storage.getItem('vuetools-color-scheme')).toBeNull();
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not persist when storageKey is null', async () => {
|
||||||
|
const prefersDark = makeMql(false);
|
||||||
|
const { win, map } = makeWindow(prefersDark);
|
||||||
|
const scope = effectScope();
|
||||||
|
let mode: ReturnType<typeof useColorMode>;
|
||||||
|
|
||||||
|
scope.run(() => {
|
||||||
|
mode = useColorMode({ window: win, storageKey: null });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
mode!.value = 'dark';
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(map.size).toBe(0);
|
||||||
|
expect(mode!.value).toBe('dark');
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emitAuto keeps the ref value as "auto" while resolving state', async () => {
|
||||||
|
const prefersDark = makeMql(true);
|
||||||
|
const { win } = makeWindow(prefersDark);
|
||||||
|
const scope = effectScope();
|
||||||
|
let mode: ReturnType<typeof useColorMode>;
|
||||||
|
|
||||||
|
scope.run(() => {
|
||||||
|
mode = useColorMode({ window: win, emitAuto: true });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(mode!.value).toBe('auto');
|
||||||
|
expect(mode!.state.value).toBe('dark');
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes to a custom attribute instead of class', async () => {
|
||||||
|
const prefersDark = makeMql(true);
|
||||||
|
const { win } = makeWindow(prefersDark);
|
||||||
|
const scope = effectScope();
|
||||||
|
let mode: ReturnType<typeof useColorMode>;
|
||||||
|
|
||||||
|
scope.run(() => {
|
||||||
|
mode = useColorMode({ window: win, attribute: 'data-theme' });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||||
|
|
||||||
|
mode!.value = 'light';
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(document.documentElement.getAttribute('data-theme')).toBe('light');
|
||||||
|
// Classes should not be touched in attribute mode.
|
||||||
|
expect(document.documentElement.classList.contains('dark')).toBeFalsy();
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports custom modes', async () => {
|
||||||
|
const prefersDark = makeMql(false);
|
||||||
|
const { win } = makeWindow(prefersDark);
|
||||||
|
const scope = effectScope();
|
||||||
|
let mode: ReturnType<typeof useColorMode<'cafe' | 'dim'>>;
|
||||||
|
|
||||||
|
scope.run(() => {
|
||||||
|
mode = useColorMode<'cafe' | 'dim'>({
|
||||||
|
window: win,
|
||||||
|
modes: { cafe: 'cafe', dim: 'dim' },
|
||||||
|
initialValue: 'cafe',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(document.documentElement.classList.contains('cafe')).toBeTruthy();
|
||||||
|
|
||||||
|
mode!.value = 'dim';
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(document.documentElement.classList.contains('dim')).toBeTruthy();
|
||||||
|
expect(document.documentElement.classList.contains('cafe')).toBeFalsy();
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invokes a custom onChanged handler instead of the default', async () => {
|
||||||
|
const prefersDark = makeMql(true);
|
||||||
|
const { win } = makeWindow(prefersDark);
|
||||||
|
const onChanged = vi.fn();
|
||||||
|
const scope = effectScope();
|
||||||
|
|
||||||
|
scope.run(() => {
|
||||||
|
useColorMode({ window: win, onChanged });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(onChanged).toHaveBeenCalled();
|
||||||
|
const [firstMode, firstHandler] = onChanged.mock.calls[0]!;
|
||||||
|
expect(firstMode).toBe('dark');
|
||||||
|
expect(typeof firstHandler).toBe('function');
|
||||||
|
// Default handler suppressed: no class applied.
|
||||||
|
expect(document.documentElement.classList.contains('dark')).toBeFalsy();
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a custom storageRef when provided', async () => {
|
||||||
|
const prefersDark = makeMql(false);
|
||||||
|
const { win, map } = makeWindow(prefersDark);
|
||||||
|
const storageRef = ref<'auto' | 'dark' | 'light'>('dark');
|
||||||
|
const scope = effectScope();
|
||||||
|
let mode: ReturnType<typeof useColorMode>;
|
||||||
|
|
||||||
|
scope.run(() => {
|
||||||
|
mode = useColorMode({ window: win, storageRef });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(mode!.value).toBe('dark');
|
||||||
|
expect(document.documentElement.classList.contains('dark')).toBeTruthy();
|
||||||
|
// storageRef bypasses useStorage, so nothing is written to localStorage.
|
||||||
|
expect(map.size).toBe(0);
|
||||||
|
|
||||||
|
storageRef.value = 'light';
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(document.documentElement.classList.contains('light')).toBeTruthy();
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw on the SSR/unsupported path (no window)', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let mode: ReturnType<typeof useColorMode>;
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
scope.run(() => {
|
||||||
|
mode = useColorMode({ window: undefined });
|
||||||
|
});
|
||||||
|
}).not.toThrow();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
// System detection unavailable -> defaults to light; state resolves to it.
|
||||||
|
expect(mode!.system.value).toBe('light');
|
||||||
|
expect(mode!.state.value).toBe('light');
|
||||||
|
// In-memory store still writable.
|
||||||
|
mode!.value = 'dark';
|
||||||
|
await nextTick();
|
||||||
|
expect(mode!.store.value).toBe('dark');
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import { computed, toRef, watch } from 'vue';
|
||||||
|
import type { ComputedRef, MaybeRefOrGetter, Ref, WritableComputedRef } from 'vue';
|
||||||
|
import { isString } from '@robonen/stdlib';
|
||||||
|
import { defaultWindow } from '@/types';
|
||||||
|
import type { ConfigurableWindow } from '@/types';
|
||||||
|
import { unrefElement } from '@/composables/component/unrefElement';
|
||||||
|
import type { MaybeElementRef } from '@/composables/component/unrefElement';
|
||||||
|
import { usePreferredDark } from '@/composables/browser/usePreferredDark';
|
||||||
|
import { useStorage } from '@/composables/storage/useStorage';
|
||||||
|
import type { UseStorageOptions } from '@/composables/storage/useStorage';
|
||||||
|
import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted';
|
||||||
|
|
||||||
|
export type BasicColorMode = 'light' | 'dark';
|
||||||
|
export type BasicColorSchema = BasicColorMode | 'auto';
|
||||||
|
|
||||||
|
export interface UseColorModeOptions<T extends string = BasicColorMode> extends UseStorageOptions<T | BasicColorMode>, ConfigurableWindow {
|
||||||
|
/**
|
||||||
|
* CSS selector (or element ref) for the target element the mode is applied to.
|
||||||
|
*
|
||||||
|
* @default 'html'
|
||||||
|
*/
|
||||||
|
selector?: string | MaybeElementRef;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML attribute applied to the target element. Use `'class'` to toggle
|
||||||
|
* classes, or any attribute name (e.g. `'data-theme'`) to set its value.
|
||||||
|
*
|
||||||
|
* @default 'class'
|
||||||
|
*/
|
||||||
|
attribute?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The initial color mode used when no value is stored.
|
||||||
|
*
|
||||||
|
* @default 'auto'
|
||||||
|
*/
|
||||||
|
initialValue?: MaybeRefOrGetter<T | BasicColorSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of color mode to the value applied to the target element. Extend this
|
||||||
|
* to support custom modes beyond `light`/`dark`/`auto`.
|
||||||
|
*
|
||||||
|
* @default { auto: '', light: 'light', dark: 'dark' }
|
||||||
|
*/
|
||||||
|
modes?: Partial<Record<T | BasicColorSchema, string>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom handler called whenever the resolved mode changes. Receives the
|
||||||
|
* resolved mode and the default handler, allowing you to opt out of (or
|
||||||
|
* extend) the default DOM update.
|
||||||
|
*/
|
||||||
|
onChanged?: (mode: T | BasicColorMode, defaultHandler: (mode: T | BasicColorMode) => void) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use a custom ref as the storage backing instead of `useStorage`.
|
||||||
|
*/
|
||||||
|
storageRef?: Ref<T | BasicColorSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The key persisted into storage. Pass `null` to disable persistence
|
||||||
|
* (the mode lives only in memory).
|
||||||
|
*
|
||||||
|
* @default 'vuetools-color-scheme'
|
||||||
|
*/
|
||||||
|
storageKey?: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom storage backend. Defaults to `window.localStorage`.
|
||||||
|
*/
|
||||||
|
storage?: Storage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit `'auto'` as the writable ref value when in auto mode, instead of the
|
||||||
|
* resolved `'light'`/`'dark'`. Useful for binding a tri-state UI.
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
emitAuto?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Briefly disable CSS transitions while the mode is applied, preventing a
|
||||||
|
* flash of transitioning colors during the switch.
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
disableTransition?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseColorModeReturn<T extends string = BasicColorMode>
|
||||||
|
= WritableComputedRef<T | BasicColorSchema> & {
|
||||||
|
store: Ref<T | BasicColorSchema>;
|
||||||
|
system: ComputedRef<BasicColorMode>;
|
||||||
|
state: ComputedRef<T | BasicColorMode>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CSS_DISABLE_TRANS = '*,*::before,*::after{-webkit-transition:none!important;-moz-transition:none!important;-o-transition:none!important;-ms-transition:none!important;transition:none!important}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useColorMode
|
||||||
|
* @category Browser
|
||||||
|
* @description Reactive color mode (`light` / `dark` / `auto`) with system
|
||||||
|
* detection, storage persistence, and automatic application of a class or
|
||||||
|
* attribute to a target element.
|
||||||
|
*
|
||||||
|
* @param {UseColorModeOptions<T>} [options={}] Options
|
||||||
|
* @returns {UseColorModeReturn<T>} A writable ref of the mode, augmented with `{ store, system, state }`
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const mode = useColorMode();
|
||||||
|
* mode.value = 'dark';
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Custom modes and a data attribute
|
||||||
|
* const mode = useColorMode({
|
||||||
|
* attribute: 'data-theme',
|
||||||
|
* modes: { dim: 'dim', cafe: 'cafe' },
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Read the resolved system + effective state
|
||||||
|
* const { system, state } = useColorMode();
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useColorMode<T extends string = BasicColorMode>(
|
||||||
|
options: UseColorModeOptions<T> = {},
|
||||||
|
): UseColorModeReturn<T> {
|
||||||
|
const {
|
||||||
|
selector = 'html',
|
||||||
|
attribute = 'class',
|
||||||
|
initialValue = 'auto',
|
||||||
|
window = defaultWindow,
|
||||||
|
storage,
|
||||||
|
storageKey = 'vuetools-color-scheme',
|
||||||
|
listenToStorageChanges = true,
|
||||||
|
storageRef,
|
||||||
|
emitAuto = false,
|
||||||
|
disableTransition = true,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const modes = {
|
||||||
|
auto: '',
|
||||||
|
light: 'light',
|
||||||
|
dark: 'dark',
|
||||||
|
...options.modes,
|
||||||
|
} as Record<BasicColorSchema | T, string>;
|
||||||
|
|
||||||
|
const preferredDark = usePreferredDark({ window });
|
||||||
|
const system = computed<BasicColorMode>(() => preferredDark.value ? 'dark' : 'light');
|
||||||
|
|
||||||
|
const resolveStore = (): Ref<T | BasicColorSchema> => {
|
||||||
|
if (storageRef)
|
||||||
|
return storageRef;
|
||||||
|
|
||||||
|
if (storageKey === null || storageKey === undefined)
|
||||||
|
return toRef(initialValue) as Ref<T | BasicColorSchema>;
|
||||||
|
|
||||||
|
const backend = storage ?? window?.localStorage;
|
||||||
|
|
||||||
|
if (!backend)
|
||||||
|
return toRef(initialValue) as Ref<T | BasicColorSchema>;
|
||||||
|
|
||||||
|
return useStorage<T | BasicColorSchema>(storageKey, initialValue, backend, {
|
||||||
|
window,
|
||||||
|
listenToStorageChanges,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const store = resolveStore();
|
||||||
|
|
||||||
|
const state = computed<T | BasicColorMode>(() =>
|
||||||
|
store.value === 'auto'
|
||||||
|
? system.value
|
||||||
|
: store.value as T | BasicColorMode);
|
||||||
|
|
||||||
|
const updateHTMLAttrs = (target: string | MaybeElementRef, attr: string, value: string): void => {
|
||||||
|
const element = isString(target)
|
||||||
|
? window?.document?.querySelector(target)
|
||||||
|
: unrefElement(target);
|
||||||
|
|
||||||
|
if (!element)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const classesToAdd = new Set<string>();
|
||||||
|
const classesToRemove = new Set<string>();
|
||||||
|
let attributeToChange: { key: string; value: string } | null = null;
|
||||||
|
|
||||||
|
if (attr === 'class') {
|
||||||
|
const next = value.split(/\s/g);
|
||||||
|
|
||||||
|
// Toggle only the classes this composable owns (derived from `modes`),
|
||||||
|
// so unrelated classes on the element are left untouched.
|
||||||
|
for (const owned of Object.values<string>(modes).flatMap(mode => (mode || '').split(/\s/g)).filter(Boolean)) {
|
||||||
|
if (next.includes(owned))
|
||||||
|
classesToAdd.add(owned);
|
||||||
|
else
|
||||||
|
classesToRemove.add(owned);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
attributeToChange = { key: attr, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (classesToAdd.size === 0 && classesToRemove.size === 0 && attributeToChange === null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
let style: HTMLStyleElement | undefined;
|
||||||
|
|
||||||
|
if (disableTransition && window?.document) {
|
||||||
|
style = window.document.createElement('style');
|
||||||
|
style.append(window.document.createTextNode(CSS_DISABLE_TRANS));
|
||||||
|
window.document.head.append(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const className of classesToAdd)
|
||||||
|
element.classList.add(className);
|
||||||
|
|
||||||
|
for (const className of classesToRemove)
|
||||||
|
element.classList.remove(className);
|
||||||
|
|
||||||
|
if (attributeToChange)
|
||||||
|
element.setAttribute(attributeToChange.key, attributeToChange.value);
|
||||||
|
|
||||||
|
if (style && window?.document) {
|
||||||
|
// Force a reflow so the no-transition style is flushed before removal.
|
||||||
|
void window.getComputedStyle(style).opacity;
|
||||||
|
style.remove();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultOnChanged = (mode: T | BasicColorMode): void => {
|
||||||
|
updateHTMLAttrs(selector, attribute, modes[mode] ?? mode);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onChanged = (mode: T | BasicColorMode): void => {
|
||||||
|
if (options.onChanged)
|
||||||
|
options.onChanged(mode, defaultOnChanged);
|
||||||
|
else
|
||||||
|
defaultOnChanged(mode);
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(state, onChanged, { flush: 'post', immediate: true });
|
||||||
|
|
||||||
|
tryOnMounted(() => onChanged(state.value));
|
||||||
|
|
||||||
|
const mode = computed<T | BasicColorSchema>({
|
||||||
|
get() {
|
||||||
|
return emitAuto ? store.value : state.value;
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
store.value = value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.assign(mode, { store, system, state }) as UseColorModeReturn<T>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope, isReadonly, nextTick } from 'vue';
|
||||||
|
import { useDevicePixelRatio } from '.';
|
||||||
|
|
||||||
|
type Listener = (event: { matches: boolean }) => void;
|
||||||
|
|
||||||
|
interface StubMql {
|
||||||
|
readonly matches: boolean;
|
||||||
|
media: string;
|
||||||
|
addEventListener: (type: string, cb: Listener) => void;
|
||||||
|
removeEventListener: (type: string, cb: Listener) => void;
|
||||||
|
dispatch: (value: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMql(media = ''): StubMql {
|
||||||
|
const listeners = new Set<Listener>();
|
||||||
|
let matches = true;
|
||||||
|
|
||||||
|
return {
|
||||||
|
get matches() {
|
||||||
|
return matches;
|
||||||
|
},
|
||||||
|
media,
|
||||||
|
addEventListener: (_: string, cb: Listener) => listeners.add(cb),
|
||||||
|
removeEventListener: (_: string, cb: Listener) => listeners.delete(cb),
|
||||||
|
dispatch(value: boolean) {
|
||||||
|
matches = value;
|
||||||
|
for (const cb of listeners) cb({ matches: value });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns one MediaQueryList per query string so re-binding can be exercised. */
|
||||||
|
function stubMatchMediaByQuery() {
|
||||||
|
const map = new Map<string, StubMql>();
|
||||||
|
const spy = vi.fn((query: string) => {
|
||||||
|
if (!map.has(query))
|
||||||
|
map.set(query, makeMql(query));
|
||||||
|
return map.get(query)!;
|
||||||
|
});
|
||||||
|
vi.stubGlobal('matchMedia', spy);
|
||||||
|
return { spy, map };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A minimal window stub whose `devicePixelRatio` can be mutated in tests. */
|
||||||
|
function makeWindowStub(initialRatio: number): Window & { devicePixelRatio: number } {
|
||||||
|
return {
|
||||||
|
devicePixelRatio: initialRatio,
|
||||||
|
matchMedia: globalThis.matchMedia,
|
||||||
|
} as unknown as Window & { devicePixelRatio: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(useDevicePixelRatio, () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('matchMedia', undefined);
|
||||||
|
});
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('reflects the initial devicePixelRatio', async () => {
|
||||||
|
stubMatchMediaByQuery();
|
||||||
|
const window = makeWindowStub(2);
|
||||||
|
const scope = effectScope();
|
||||||
|
let result: ReturnType<typeof useDevicePixelRatio>;
|
||||||
|
scope.run(() => {
|
||||||
|
result = useDevicePixelRatio({ window });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
expect(result!.pixelRatio.value).toBe(2);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a readonly ref', async () => {
|
||||||
|
stubMatchMediaByQuery();
|
||||||
|
const window = makeWindowStub(1);
|
||||||
|
const scope = effectScope();
|
||||||
|
let result: ReturnType<typeof useDevicePixelRatio>;
|
||||||
|
scope.run(() => {
|
||||||
|
result = useDevicePixelRatio({ window });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
expect(isReadonly(result!.pixelRatio)).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates when the resolution media query flips', async () => {
|
||||||
|
const { map } = stubMatchMediaByQuery();
|
||||||
|
const window = makeWindowStub(1);
|
||||||
|
const scope = effectScope();
|
||||||
|
let result: ReturnType<typeof useDevicePixelRatio>;
|
||||||
|
scope.run(() => {
|
||||||
|
result = useDevicePixelRatio({ window });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
expect(result!.pixelRatio.value).toBe(1);
|
||||||
|
|
||||||
|
// Simulate a zoom: the real ratio changes, then the current query flips.
|
||||||
|
window.devicePixelRatio = 2;
|
||||||
|
map.get('(resolution: 1dppx)')!.dispatch(false);
|
||||||
|
await nextTick();
|
||||||
|
expect(result!.pixelRatio.value).toBe(2);
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-binds the listener after the ratio changes', async () => {
|
||||||
|
const { spy, map } = stubMatchMediaByQuery();
|
||||||
|
const window = makeWindowStub(1);
|
||||||
|
const scope = effectScope();
|
||||||
|
let result: ReturnType<typeof useDevicePixelRatio>;
|
||||||
|
scope.run(() => {
|
||||||
|
result = useDevicePixelRatio({ window });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
window.devicePixelRatio = 3;
|
||||||
|
map.get('(resolution: 1dppx)')!.dispatch(false);
|
||||||
|
await nextTick();
|
||||||
|
expect(result!.pixelRatio.value).toBe(3);
|
||||||
|
|
||||||
|
// The query string should now track 3dppx (new MediaQueryList created).
|
||||||
|
expect(spy).toHaveBeenCalledWith('(resolution: 3dppx)');
|
||||||
|
|
||||||
|
// Further changes are driven by the new MediaQueryList.
|
||||||
|
window.devicePixelRatio = 1.5;
|
||||||
|
map.get('(resolution: 3dppx)')!.dispatch(false);
|
||||||
|
await nextTick();
|
||||||
|
expect(result!.pixelRatio.value).toBe(1.5);
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops tracking after stop()', async () => {
|
||||||
|
const { map } = stubMatchMediaByQuery();
|
||||||
|
const window = makeWindowStub(1);
|
||||||
|
const scope = effectScope();
|
||||||
|
let result: ReturnType<typeof useDevicePixelRatio>;
|
||||||
|
scope.run(() => {
|
||||||
|
result = useDevicePixelRatio({ window });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
result!.stop();
|
||||||
|
|
||||||
|
window.devicePixelRatio = 2;
|
||||||
|
map.get('(resolution: 1dppx)')!.dispatch(false);
|
||||||
|
await nextTick();
|
||||||
|
expect(result!.pixelRatio.value).toBe(1);
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to 1 with a no-op stop when no window is available (SSR)', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let result: ReturnType<typeof useDevicePixelRatio>;
|
||||||
|
scope.run(() => {
|
||||||
|
result = useDevicePixelRatio({ window: undefined });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
expect(result!.pixelRatio.value).toBe(1);
|
||||||
|
// stop() must be safe to call even when nothing was bound.
|
||||||
|
expect(() => result!.stop()).not.toThrow();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { noop } from '@robonen/stdlib';
|
||||||
|
import { shallowReadonly, shallowRef, watch } from 'vue';
|
||||||
|
import type { ShallowRef, WatchStopHandle } from 'vue';
|
||||||
|
import { defaultWindow } from '@/types';
|
||||||
|
import type { ConfigurableWindow } from '@/types';
|
||||||
|
import { useMediaQuery } from '@/composables/browser/useMediaQuery';
|
||||||
|
|
||||||
|
export interface UseDevicePixelRatioOptions extends ConfigurableWindow {}
|
||||||
|
|
||||||
|
export interface UseDevicePixelRatioReturn {
|
||||||
|
/**
|
||||||
|
* Reactive, readonly `window.devicePixelRatio`. Defaults to `1` on the
|
||||||
|
* server / when no window is available.
|
||||||
|
*/
|
||||||
|
pixelRatio: Readonly<ShallowRef<number>>;
|
||||||
|
/**
|
||||||
|
* Stop tracking the device pixel ratio. Idempotent and a no-op on SSR.
|
||||||
|
*/
|
||||||
|
stop: WatchStopHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useDevicePixelRatio
|
||||||
|
* @category Browser
|
||||||
|
* @description Reactively track `window.devicePixelRatio`, updated via a
|
||||||
|
* `matchMedia(resolution)` listener (fires on zoom and on monitor changes).
|
||||||
|
*
|
||||||
|
* @param {UseDevicePixelRatioOptions} [options={}] Options (custom `window`)
|
||||||
|
* @returns {UseDevicePixelRatioReturn} `{ pixelRatio, stop }`
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { pixelRatio } = useDevicePixelRatio();
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useDevicePixelRatio(options: UseDevicePixelRatioOptions = {}): UseDevicePixelRatioReturn {
|
||||||
|
const { window = defaultWindow } = options;
|
||||||
|
|
||||||
|
const pixelRatio = shallowRef(1);
|
||||||
|
|
||||||
|
// `devicePixelRatio` has no `change` event; the canonical trick is to watch a
|
||||||
|
// `(resolution: Ndppx)` media query whose threshold tracks the current ratio.
|
||||||
|
// When the real ratio crosses that threshold the query flips, re-evaluating
|
||||||
|
// the reactive query string and re-binding to a fresh MediaQueryList.
|
||||||
|
const query = useMediaQuery(() => `(resolution: ${pixelRatio.value}dppx)`, options);
|
||||||
|
|
||||||
|
let stop: WatchStopHandle = noop;
|
||||||
|
|
||||||
|
if (window) {
|
||||||
|
stop = watch(
|
||||||
|
query,
|
||||||
|
() => {
|
||||||
|
pixelRatio.value = window.devicePixelRatio;
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pixelRatio: shallowReadonly(pixelRatio),
|
||||||
|
stop,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope, nextTick } from 'vue';
|
||||||
|
import { useDocumentReadyState } from '.';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
Object.defineProperty(document, 'readyState', { value: 'complete', configurable: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function setReadyState(state: DocumentReadyState) {
|
||||||
|
Object.defineProperty(document, 'readyState', { value: state, configurable: true });
|
||||||
|
document.dispatchEvent(new Event('readystatechange'));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(useDocumentReadyState, () => {
|
||||||
|
it('reads the current ready state', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let readyState: ReturnType<typeof useDocumentReadyState>;
|
||||||
|
scope.run(() => {
|
||||||
|
readyState = useDocumentReadyState();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(readyState!.value).toBe('complete');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reflects a non-default initial state at setup time', () => {
|
||||||
|
Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true });
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let readyState: ReturnType<typeof useDocumentReadyState>;
|
||||||
|
scope.run(() => {
|
||||||
|
readyState = useDocumentReadyState();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(readyState!.value).toBe('loading');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates on readystatechange', async () => {
|
||||||
|
Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true });
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let readyState: ReturnType<typeof useDocumentReadyState>;
|
||||||
|
scope.run(() => {
|
||||||
|
readyState = useDocumentReadyState();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(readyState!.value).toBe('loading');
|
||||||
|
|
||||||
|
setReadyState('interactive');
|
||||||
|
await nextTick();
|
||||||
|
expect(readyState!.value).toBe('interactive');
|
||||||
|
|
||||||
|
setReadyState('complete');
|
||||||
|
await nextTick();
|
||||||
|
expect(readyState!.value).toBe('complete');
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invokes onChange with new state, previous state, and the event', async () => {
|
||||||
|
Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true });
|
||||||
|
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
useDocumentReadyState({ onChange });
|
||||||
|
});
|
||||||
|
|
||||||
|
setReadyState('interactive');
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||||||
|
const [state, previous, event] = onChange.mock.calls[0]!;
|
||||||
|
expect(state).toBe('interactive');
|
||||||
|
expect(previous).toBe('loading');
|
||||||
|
expect(event).toBeInstanceOf(Event);
|
||||||
|
|
||||||
|
setReadyState('complete');
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(2);
|
||||||
|
expect(onChange.mock.calls[1]!.slice(0, 2)).toEqual(['complete', 'interactive']);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not update or fire onChange when the state is unchanged', async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const scope = effectScope();
|
||||||
|
let readyState: ReturnType<typeof useDocumentReadyState>;
|
||||||
|
scope.run(() => {
|
||||||
|
readyState = useDocumentReadyState({ onChange });
|
||||||
|
});
|
||||||
|
|
||||||
|
// readyState is already 'complete'; dispatching with no real change is a no-op
|
||||||
|
document.dispatchEvent(new Event('readystatechange'));
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
expect(readyState!.value).toBe('complete');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is SSR-safe and returns "loading" without a document', () => {
|
||||||
|
// Passing `document: undefined` resolves to the default document, so to exercise the
|
||||||
|
// no-document branch we cast a falsy value that bypasses the default-parameter logic.
|
||||||
|
const scope = effectScope();
|
||||||
|
let readyState: ReturnType<typeof useDocumentReadyState>;
|
||||||
|
scope.run(() => {
|
||||||
|
readyState = useDocumentReadyState({ document: null as unknown as Document });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(readyState!.value).toBe('loading');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a custom document instance', async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
let listener: ((event: Event) => void) | undefined;
|
||||||
|
const customDoc = {
|
||||||
|
readyState: 'loading' as DocumentReadyState,
|
||||||
|
addEventListener: (_type: string, cb: (event: Event) => void) => { listener = cb; },
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
} as unknown as Document;
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let readyState: ReturnType<typeof useDocumentReadyState>;
|
||||||
|
scope.run(() => {
|
||||||
|
readyState = useDocumentReadyState({ document: customDoc, onChange });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(readyState!.value).toBe('loading');
|
||||||
|
|
||||||
|
(customDoc as { readyState: DocumentReadyState }).readyState = 'complete';
|
||||||
|
listener?.(new Event('readystatechange'));
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(readyState!.value).toBe('complete');
|
||||||
|
expect(onChange).toHaveBeenCalledWith('complete', 'loading', expect.any(Event));
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { shallowRef } from 'vue';
|
||||||
|
import type { ShallowRef } from 'vue';
|
||||||
|
import { defaultDocument } from '@/types';
|
||||||
|
import type { ConfigurableDocument } from '@/types';
|
||||||
|
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||||
|
|
||||||
|
export interface UseDocumentReadyStateOptions extends ConfigurableDocument {
|
||||||
|
/**
|
||||||
|
* Called whenever `document.readyState` changes, receiving the new state,
|
||||||
|
* the previous state, and the originating `readystatechange` event.
|
||||||
|
*
|
||||||
|
* @default undefined
|
||||||
|
*/
|
||||||
|
onChange?: (
|
||||||
|
state: DocumentReadyState,
|
||||||
|
previous: DocumentReadyState,
|
||||||
|
event: Event,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseDocumentReadyStateReturn = ShallowRef<DocumentReadyState>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useDocumentReadyState
|
||||||
|
* @category Browser
|
||||||
|
* @description Reactive `document.readyState` (`loading` | `interactive` | `complete`), updated on `readystatechange`.
|
||||||
|
*
|
||||||
|
* @param {UseDocumentReadyStateOptions} [options={}] Options (custom `document`, `onChange` callback)
|
||||||
|
* @returns {UseDocumentReadyStateReturn} The current document ready state
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const readyState = useDocumentReadyState();
|
||||||
|
* watch(readyState, (state) => {
|
||||||
|
* if (state === 'complete') runAfterLoad();
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* useDocumentReadyState({
|
||||||
|
* onChange: (state) => {
|
||||||
|
* if (state === 'interactive') hydrate();
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useDocumentReadyState(
|
||||||
|
options: UseDocumentReadyStateOptions = {},
|
||||||
|
): UseDocumentReadyStateReturn {
|
||||||
|
const { document = defaultDocument, onChange } = options;
|
||||||
|
|
||||||
|
const readyState = shallowRef<DocumentReadyState>(document?.readyState ?? 'loading');
|
||||||
|
|
||||||
|
if (document) {
|
||||||
|
useEventListener(document, 'readystatechange', (event) => {
|
||||||
|
const previous = readyState.value;
|
||||||
|
const state = document.readyState;
|
||||||
|
|
||||||
|
if (state === previous)
|
||||||
|
return;
|
||||||
|
|
||||||
|
readyState.value = state;
|
||||||
|
onChange?.(state, previous, event);
|
||||||
|
}, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return readyState;
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope, nextTick } from 'vue';
|
||||||
|
import { useDocumentVisibility } from '.';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function setVisibility(state: DocumentVisibilityState) {
|
||||||
|
Object.defineProperty(document, 'visibilityState', { value: state, configurable: true });
|
||||||
|
document.dispatchEvent(new Event('visibilitychange'));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(useDocumentVisibility, () => {
|
||||||
|
it('reads the current visibility state', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let visibility: ReturnType<typeof useDocumentVisibility>;
|
||||||
|
scope.run(() => {
|
||||||
|
visibility = useDocumentVisibility();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(visibility!.value).toBe('visible');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates on visibilitychange', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let visibility: ReturnType<typeof useDocumentVisibility>;
|
||||||
|
scope.run(() => {
|
||||||
|
visibility = useDocumentVisibility();
|
||||||
|
});
|
||||||
|
|
||||||
|
setVisibility('hidden');
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(visibility!.value).toBe('hidden');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invokes onChange with new state, previous state, and the event', async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
useDocumentVisibility({ onChange });
|
||||||
|
});
|
||||||
|
|
||||||
|
setVisibility('hidden');
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||||||
|
const [state, previous, event] = onChange.mock.calls[0]!;
|
||||||
|
expect(state).toBe('hidden');
|
||||||
|
expect(previous).toBe('visible');
|
||||||
|
expect(event).toBeInstanceOf(Event);
|
||||||
|
|
||||||
|
setVisibility('visible');
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(2);
|
||||||
|
expect(onChange.mock.calls[1]!.slice(0, 2)).toEqual(['visible', 'hidden']);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not update or fire onChange when the state is unchanged', async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const scope = effectScope();
|
||||||
|
let visibility: ReturnType<typeof useDocumentVisibility>;
|
||||||
|
scope.run(() => {
|
||||||
|
visibility = useDocumentVisibility({ onChange });
|
||||||
|
});
|
||||||
|
|
||||||
|
// visibilityState is already 'visible'; dispatching with no real change is a no-op
|
||||||
|
document.dispatchEvent(new Event('visibilitychange'));
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
expect(visibility!.value).toBe('visible');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reflects a non-default initial state at setup time', () => {
|
||||||
|
Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let visibility: ReturnType<typeof useDocumentVisibility>;
|
||||||
|
scope.run(() => {
|
||||||
|
visibility = useDocumentVisibility();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(visibility!.value).toBe('hidden');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is SSR-safe and returns "visible" without a document', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let visibility: ReturnType<typeof useDocumentVisibility>;
|
||||||
|
scope.run(() => {
|
||||||
|
visibility = useDocumentVisibility({ document: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(visibility!.value).toBe('visible');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a custom document instance', async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
let listener: ((event: Event) => void) | undefined;
|
||||||
|
const customDoc = {
|
||||||
|
visibilityState: 'visible' as DocumentVisibilityState,
|
||||||
|
addEventListener: (_type: string, cb: (event: Event) => void) => { listener = cb; },
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
} as unknown as Document;
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let visibility: ReturnType<typeof useDocumentVisibility>;
|
||||||
|
scope.run(() => {
|
||||||
|
visibility = useDocumentVisibility({ document: customDoc, onChange });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(visibility!.value).toBe('visible');
|
||||||
|
|
||||||
|
(customDoc as { visibilityState: DocumentVisibilityState }).visibilityState = 'hidden';
|
||||||
|
listener?.(new Event('visibilitychange'));
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(visibility!.value).toBe('hidden');
|
||||||
|
expect(onChange).toHaveBeenCalledWith('hidden', 'visible', expect.any(Event));
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { shallowRef } from 'vue';
|
||||||
|
import type { ShallowRef } from 'vue';
|
||||||
|
import { defaultDocument } from '@/types';
|
||||||
|
import type { ConfigurableDocument } from '@/types';
|
||||||
|
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||||
|
|
||||||
|
export interface UseDocumentVisibilityOptions extends ConfigurableDocument {
|
||||||
|
/**
|
||||||
|
* Called whenever `document.visibilityState` changes, receiving the new state,
|
||||||
|
* the previous state, and the originating `visibilitychange` event.
|
||||||
|
*
|
||||||
|
* @default undefined
|
||||||
|
*/
|
||||||
|
onChange?: (
|
||||||
|
state: DocumentVisibilityState,
|
||||||
|
previous: DocumentVisibilityState,
|
||||||
|
event: Event,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseDocumentVisibilityReturn = ShallowRef<DocumentVisibilityState>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useDocumentVisibility
|
||||||
|
* @category Browser
|
||||||
|
* @description Reactive `document.visibilityState`.
|
||||||
|
*
|
||||||
|
* @param {UseDocumentVisibilityOptions} [options={}] Options (custom `document`, `onChange` callback)
|
||||||
|
* @returns {UseDocumentVisibilityReturn} The current visibility state
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const visibility = useDocumentVisibility();
|
||||||
|
* watch(visibility, (state) => {
|
||||||
|
* if (state === 'visible') refresh();
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* useDocumentVisibility({
|
||||||
|
* onChange: (state) => {
|
||||||
|
* if (state === 'hidden') pausePlayback();
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useDocumentVisibility(
|
||||||
|
options: UseDocumentVisibilityOptions = {},
|
||||||
|
): UseDocumentVisibilityReturn {
|
||||||
|
const { document = defaultDocument, onChange } = options;
|
||||||
|
|
||||||
|
const visibility = shallowRef<DocumentVisibilityState>(document?.visibilityState ?? 'visible');
|
||||||
|
|
||||||
|
if (document) {
|
||||||
|
useEventListener(document, 'visibilitychange', (event) => {
|
||||||
|
const previous = visibility.value;
|
||||||
|
const state = document.visibilityState;
|
||||||
|
|
||||||
|
if (state === previous)
|
||||||
|
return;
|
||||||
|
|
||||||
|
visibility.value = state;
|
||||||
|
onChange?.(state, previous, event);
|
||||||
|
}, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return visibility;
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope, nextTick, ref } from 'vue';
|
||||||
|
import { useDropZone } from '.';
|
||||||
|
|
||||||
|
interface FakeDataTransfer {
|
||||||
|
files: File[];
|
||||||
|
items: Array<{ type: string }>;
|
||||||
|
dropEffect: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFile(name = 'a.png', type = 'image/png'): File {
|
||||||
|
return new File(['x'], name, { type });
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsdom lacks DragEvent / DataTransfer, so we synthesize an Event with a dataTransfer payload.
|
||||||
|
function dispatchDrag(
|
||||||
|
el: EventTarget,
|
||||||
|
type: 'dragenter' | 'dragover' | 'dragleave' | 'drop',
|
||||||
|
files: File[] = [],
|
||||||
|
): { event: Event; dataTransfer: FakeDataTransfer } {
|
||||||
|
const dataTransfer: FakeDataTransfer = {
|
||||||
|
files,
|
||||||
|
items: files.map(f => ({ type: f.type })),
|
||||||
|
dropEffect: 'none',
|
||||||
|
};
|
||||||
|
|
||||||
|
const event = new Event(type, { bubbles: true, cancelable: true });
|
||||||
|
Object.defineProperty(event, 'dataTransfer', { value: dataTransfer, configurable: true });
|
||||||
|
|
||||||
|
el.dispatchEvent(event);
|
||||||
|
return { event, dataTransfer };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(useDropZone, () => {
|
||||||
|
let el: HTMLElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
el = document.createElement('div');
|
||||||
|
document.body.appendChild(el);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
el.remove();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes reactive state', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
const { isOverDropZone, files, isSupported } = useDropZone(el);
|
||||||
|
expect(isOverDropZone.value).toBeFalsy();
|
||||||
|
expect(files.value).toBeNull();
|
||||||
|
expect(isSupported).toBeDefined();
|
||||||
|
});
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets isOverDropZone on dragenter and clears on matching dragleave', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
const { isOverDropZone } = useDropZone(el);
|
||||||
|
|
||||||
|
dispatchDrag(el, 'dragenter', [makeFile()]);
|
||||||
|
expect(isOverDropZone.value).toBeTruthy();
|
||||||
|
|
||||||
|
dispatchDrag(el, 'dragleave', [makeFile()]);
|
||||||
|
expect(isOverDropZone.value).toBeFalsy();
|
||||||
|
});
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a counter so nested enter/leave keeps isOverDropZone true', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
const { isOverDropZone } = useDropZone(el);
|
||||||
|
|
||||||
|
dispatchDrag(el, 'dragenter', [makeFile()]);
|
||||||
|
dispatchDrag(el, 'dragenter', [makeFile()]);
|
||||||
|
expect(isOverDropZone.value).toBeTruthy();
|
||||||
|
|
||||||
|
dispatchDrag(el, 'dragleave', [makeFile()]);
|
||||||
|
expect(isOverDropZone.value).toBeTruthy();
|
||||||
|
|
||||||
|
dispatchDrag(el, 'dragleave', [makeFile()]);
|
||||||
|
expect(isOverDropZone.value).toBeFalsy();
|
||||||
|
});
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collects dropped files and resets isOverDropZone', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
const { files, isOverDropZone } = useDropZone(el);
|
||||||
|
|
||||||
|
dispatchDrag(el, 'dragenter', [makeFile()]);
|
||||||
|
const dropped = [makeFile('one.png'), makeFile('two.png')];
|
||||||
|
dispatchDrag(el, 'drop', dropped);
|
||||||
|
|
||||||
|
expect(files.value).toHaveLength(2);
|
||||||
|
expect(files.value?.[0]!.name).toBe('one.png');
|
||||||
|
expect(isOverDropZone.value).toBeFalsy();
|
||||||
|
});
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invokes lifecycle callbacks', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
const onEnter = vi.fn();
|
||||||
|
const onOver = vi.fn();
|
||||||
|
const onLeave = vi.fn();
|
||||||
|
const onDrop = vi.fn();
|
||||||
|
|
||||||
|
useDropZone(el, { onEnter, onOver, onLeave, onDrop });
|
||||||
|
|
||||||
|
const f = [makeFile()];
|
||||||
|
dispatchDrag(el, 'dragenter', f);
|
||||||
|
dispatchDrag(el, 'dragover', f);
|
||||||
|
dispatchDrag(el, 'dragleave', f);
|
||||||
|
dispatchDrag(el, 'drop', f);
|
||||||
|
|
||||||
|
expect(onEnter).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onOver).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onLeave).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onDrop).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onEnter).toHaveBeenCalledWith(null, expect.any(Event));
|
||||||
|
expect(onDrop.mock.calls[0]![0]).toHaveLength(1);
|
||||||
|
});
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a shorthand onDrop function as options', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
const onDrop = vi.fn();
|
||||||
|
useDropZone(el, onDrop);
|
||||||
|
|
||||||
|
dispatchDrag(el, 'drop', [makeFile()]);
|
||||||
|
expect(onDrop).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects multiple: false by keeping only the first file', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
const { files } = useDropZone(el, { multiple: false });
|
||||||
|
|
||||||
|
// Two files dragged: validation should reject, so drop is ignored
|
||||||
|
dispatchDrag(el, 'drop', [makeFile('a.png'), makeFile('b.png')]);
|
||||||
|
expect(files.value).toBeNull();
|
||||||
|
|
||||||
|
// Single file passes and only the first is kept
|
||||||
|
dispatchDrag(el, 'drop', [makeFile('solo.png')]);
|
||||||
|
expect(files.value).toHaveLength(1);
|
||||||
|
expect(files.value?.[0]!.name).toBe('solo.png');
|
||||||
|
});
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by dataTypes array', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
const onDrop = vi.fn();
|
||||||
|
const { files } = useDropZone(el, { dataTypes: ['image/png'], onDrop });
|
||||||
|
|
||||||
|
// wrong type rejected
|
||||||
|
dispatchDrag(el, 'drop', [makeFile('doc.pdf', 'application/pdf')]);
|
||||||
|
expect(files.value).toBeNull();
|
||||||
|
expect(onDrop).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// correct type accepted
|
||||||
|
dispatchDrag(el, 'drop', [makeFile('img.png', 'image/png')]);
|
||||||
|
expect(files.value).toHaveLength(1);
|
||||||
|
expect(onDrop).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports dataTypes as a predicate function', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
const predicate = vi.fn((types: readonly string[]) => types.includes('image/png'));
|
||||||
|
const { files } = useDropZone(el, { dataTypes: predicate });
|
||||||
|
|
||||||
|
dispatchDrag(el, 'drop', [makeFile('img.png', 'image/png')]);
|
||||||
|
expect(predicate).toHaveBeenCalled();
|
||||||
|
expect(files.value).toHaveLength(1);
|
||||||
|
});
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reacts to a reactive dataTypes ref', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
const allowed = ref<string[]>(['image/png']);
|
||||||
|
const { files } = useDropZone(el, { dataTypes: allowed });
|
||||||
|
|
||||||
|
dispatchDrag(el, 'drop', [makeFile('doc.pdf', 'application/pdf')]);
|
||||||
|
expect(files.value).toBeNull();
|
||||||
|
|
||||||
|
allowed.value = ['application/pdf'];
|
||||||
|
dispatchDrag(el, 'drop', [makeFile('doc.pdf', 'application/pdf')]);
|
||||||
|
expect(files.value).toHaveLength(1);
|
||||||
|
});
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets dropEffect to none for invalid drags', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
useDropZone(el, { dataTypes: ['image/png'] });
|
||||||
|
|
||||||
|
const { dataTransfer } = dispatchDrag(el, 'dragenter', [makeFile('doc.pdf', 'application/pdf')]);
|
||||||
|
expect(dataTransfer.dropEffect).toBe('none');
|
||||||
|
});
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets dropEffect to copy for valid drags', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
useDropZone(el, { dataTypes: ['image/png'] });
|
||||||
|
|
||||||
|
const { dataTransfer } = dispatchDrag(el, 'dragenter', [makeFile('img.png', 'image/png')]);
|
||||||
|
expect(dataTransfer.dropEffect).toBe('copy');
|
||||||
|
});
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preventDefaultForUnhandled calls preventDefault on invalid drags', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
useDropZone(el, { dataTypes: ['image/png'], preventDefaultForUnhandled: true });
|
||||||
|
|
||||||
|
const { event } = dispatchDrag(el, 'dragenter', [makeFile('doc.pdf', 'application/pdf')]);
|
||||||
|
expect(event.defaultPrevented).toBeTruthy();
|
||||||
|
});
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with a reactive element ref target', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
await scope.run(async () => {
|
||||||
|
const target = ref<HTMLElement | null>(null);
|
||||||
|
const { isOverDropZone } = useDropZone(target);
|
||||||
|
|
||||||
|
target.value = el;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
dispatchDrag(el, 'dragenter', [makeFile()]);
|
||||||
|
expect(isOverDropZone.value).toBeTruthy();
|
||||||
|
});
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with document as the target', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
const { isOverDropZone } = useDropZone(document);
|
||||||
|
|
||||||
|
dispatchDrag(document, 'dragenter', [makeFile()]);
|
||||||
|
expect(isOverDropZone.value).toBeTruthy();
|
||||||
|
});
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops listening after the scope is disposed', () => {
|
||||||
|
const onDrop = vi.fn();
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
useDropZone(el, { onDrop });
|
||||||
|
});
|
||||||
|
scope.stop();
|
||||||
|
|
||||||
|
dispatchDrag(el, 'drop', [makeFile()]);
|
||||||
|
expect(onDrop).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports isSupported via the configurable window option', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
const { isSupported } = useDropZone(el, { window: undefined });
|
||||||
|
expect(isSupported.value).toBeFalsy();
|
||||||
|
});
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import type { ComputedRef, MaybeRef, MaybeRefOrGetter, ShallowRef } from 'vue';
|
||||||
|
import { shallowRef, toValue, unref } from 'vue';
|
||||||
|
import { isFunction } from '@robonen/stdlib';
|
||||||
|
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||||
|
import { useSupported } from '@/composables/browser/useSupported';
|
||||||
|
import { unrefElement } from '@/composables/component/unrefElement';
|
||||||
|
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
||||||
|
import { defaultNavigator, defaultWindow } from '@/types';
|
||||||
|
import type { ConfigurableNavigator, ConfigurableWindow } from '@/types';
|
||||||
|
|
||||||
|
export type UseDropZoneDataTypes = MaybeRef<readonly string[]> | ((types: readonly string[]) => boolean);
|
||||||
|
|
||||||
|
export interface UseDropZoneOptions extends ConfigurableWindow, ConfigurableNavigator {
|
||||||
|
/**
|
||||||
|
* Allowed data types. If not set, all data types are allowed.
|
||||||
|
* Can also be a predicate that receives the dragged item types and returns whether they are valid.
|
||||||
|
*/
|
||||||
|
dataTypes?: UseDropZoneDataTypes;
|
||||||
|
/**
|
||||||
|
* Allow multiple files to be dropped.
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
multiple?: boolean;
|
||||||
|
/**
|
||||||
|
* Call `preventDefault` even for drags that fail validation, suppressing the browser's default handling.
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
preventDefaultForUnhandled?: boolean;
|
||||||
|
/**
|
||||||
|
* Fired when valid files are dropped on the target.
|
||||||
|
*/
|
||||||
|
onDrop?: (files: File[] | null, event: DragEvent) => void;
|
||||||
|
/**
|
||||||
|
* Fired when a drag enters the target.
|
||||||
|
*/
|
||||||
|
onEnter?: (files: File[] | null, event: DragEvent) => void;
|
||||||
|
/**
|
||||||
|
* Fired when a drag leaves the target.
|
||||||
|
*/
|
||||||
|
onLeave?: (files: File[] | null, event: DragEvent) => void;
|
||||||
|
/**
|
||||||
|
* Fired repeatedly while a drag hovers over the target.
|
||||||
|
*/
|
||||||
|
onOver?: (files: File[] | null, event: DragEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseDropZoneReturn {
|
||||||
|
/**
|
||||||
|
* Whether a valid drag is currently hovering over the target.
|
||||||
|
*/
|
||||||
|
isOverDropZone: ShallowRef<boolean>;
|
||||||
|
/**
|
||||||
|
* The dropped files, or `null` when nothing has been dropped yet.
|
||||||
|
*/
|
||||||
|
files: ShallowRef<File[] | null>;
|
||||||
|
/**
|
||||||
|
* Whether the Drag and Drop API is available in the current environment.
|
||||||
|
*/
|
||||||
|
isSupported: ComputedRef<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DropZoneEventType = 'enter' | 'over' | 'leave' | 'drop';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useDropZone
|
||||||
|
* @category Browser
|
||||||
|
* @description Create a drag-and-drop file drop zone on a target element or document.
|
||||||
|
*
|
||||||
|
* @param {MaybeComputedElementRef | MaybeRefOrGetter<Document | null | undefined>} target - The element (or document) acting as the drop zone.
|
||||||
|
* @param {UseDropZoneOptions | UseDropZoneOptions['onDrop']} [options] - Drop zone options, or a shorthand `onDrop` callback.
|
||||||
|
* @returns {UseDropZoneReturn} The reactive drop zone state.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const dropZone = useTemplateRef<HTMLElement>('dropZone');
|
||||||
|
* const { isOverDropZone, files } = useDropZone(dropZone, {
|
||||||
|
* dataTypes: ['image/png'],
|
||||||
|
* onDrop: (files) => console.log(files),
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useDropZone(
|
||||||
|
target: MaybeComputedElementRef | MaybeRefOrGetter<Document | null | undefined>,
|
||||||
|
options: UseDropZoneOptions | UseDropZoneOptions['onDrop'] = {},
|
||||||
|
): UseDropZoneReturn {
|
||||||
|
const _options: UseDropZoneOptions = isFunction(options) ? { onDrop: options } : options;
|
||||||
|
const {
|
||||||
|
window = defaultWindow,
|
||||||
|
navigator = defaultNavigator,
|
||||||
|
multiple = true,
|
||||||
|
preventDefaultForUnhandled = false,
|
||||||
|
} = _options;
|
||||||
|
|
||||||
|
const isOverDropZone = shallowRef(false);
|
||||||
|
const files = shallowRef<File[] | null>(null);
|
||||||
|
const isSupported = useSupported(() => window && 'DataTransfer' in window);
|
||||||
|
|
||||||
|
let counter = 0;
|
||||||
|
let isValid = true;
|
||||||
|
|
||||||
|
const getFiles = (event: DragEvent): File[] | null => {
|
||||||
|
const list = Array.from(event.dataTransfer?.files ?? []);
|
||||||
|
if (list.length === 0)
|
||||||
|
return null;
|
||||||
|
return multiple ? list : [list[0]!];
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkDataTypes = (types: readonly string[]): boolean => {
|
||||||
|
// `dataTypes` may be a predicate function, so unwrap with `unref` (not `toValue`,
|
||||||
|
// which would call a function as a getter).
|
||||||
|
const dataTypes = unref(_options.dataTypes);
|
||||||
|
|
||||||
|
if (isFunction(dataTypes))
|
||||||
|
return dataTypes(types);
|
||||||
|
|
||||||
|
if (!dataTypes?.length)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (types.length === 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return types.every(type => dataTypes.some(allowed => type.includes(allowed)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkValidity = (items: DataTransferItemList): boolean => {
|
||||||
|
const types = Array.from(items ?? []).map(item => item.type);
|
||||||
|
const dataTypesValid = checkDataTypes(types);
|
||||||
|
const multipleFilesValid = multiple || items.length <= 1;
|
||||||
|
|
||||||
|
return dataTypesValid && multipleFilesValid;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Safari fires drag events without populating `dataTransfer.items`, so validation
|
||||||
|
// cannot be trusted there — always accept the drag and let `drop` resolve files.
|
||||||
|
const isSafari = (): boolean => {
|
||||||
|
if (!navigator || !window)
|
||||||
|
return false;
|
||||||
|
return /^(?:(?!chrome|android).)*safari/i.test(navigator.userAgent) && !('chrome' in window);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEvent = (event: DragEvent, type: DropZoneEventType): void => {
|
||||||
|
const items = event.dataTransfer?.items;
|
||||||
|
isValid = (items && checkValidity(items)) ?? false;
|
||||||
|
|
||||||
|
if (preventDefaultForUnhandled)
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!isSafari() && !isValid) {
|
||||||
|
if (event.dataTransfer)
|
||||||
|
event.dataTransfer.dropEffect = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
if (event.dataTransfer)
|
||||||
|
event.dataTransfer.dropEffect = 'copy';
|
||||||
|
|
||||||
|
const currentFiles = getFiles(event);
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'enter':
|
||||||
|
counter += 1;
|
||||||
|
isOverDropZone.value = true;
|
||||||
|
_options.onEnter?.(null, event);
|
||||||
|
break;
|
||||||
|
case 'over':
|
||||||
|
_options.onOver?.(null, event);
|
||||||
|
break;
|
||||||
|
case 'leave':
|
||||||
|
counter -= 1;
|
||||||
|
if (counter === 0)
|
||||||
|
isOverDropZone.value = false;
|
||||||
|
_options.onLeave?.(null, event);
|
||||||
|
break;
|
||||||
|
case 'drop':
|
||||||
|
counter = 0;
|
||||||
|
isOverDropZone.value = false;
|
||||||
|
if (isValid) {
|
||||||
|
files.value = currentFiles;
|
||||||
|
_options.onDrop?.(currentFiles, event);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveTarget = (): EventTarget | null | undefined => {
|
||||||
|
const value = toValue(target as MaybeRefOrGetter<unknown>);
|
||||||
|
if (value instanceof Document)
|
||||||
|
return value;
|
||||||
|
return unrefElement(target as MaybeComputedElementRef);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEventListener<DragEvent>(resolveTarget, 'dragenter', event => handleDragEvent(event, 'enter'));
|
||||||
|
useEventListener<DragEvent>(resolveTarget, 'dragover', event => handleDragEvent(event, 'over'));
|
||||||
|
useEventListener<DragEvent>(resolveTarget, 'dragleave', event => handleDragEvent(event, 'leave'));
|
||||||
|
useEventListener<DragEvent>(resolveTarget, 'drop', event => handleDragEvent(event, 'drop'));
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOverDropZone,
|
||||||
|
files,
|
||||||
|
isSupported,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope, ref } from 'vue';
|
||||||
|
import { useElementBounding } from '.';
|
||||||
|
|
||||||
|
class StubObserver {
|
||||||
|
observe = vi.fn();
|
||||||
|
disconnect = vi.fn();
|
||||||
|
unobserve = vi.fn();
|
||||||
|
takeRecords = vi.fn(() => []);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(useElementBounding, () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('ResizeObserver', StubObserver);
|
||||||
|
vi.stubGlobal('MutationObserver', StubObserver);
|
||||||
|
});
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('reads the bounding rect immediately', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.getBoundingClientRect = () => ({
|
||||||
|
width: 100, height: 50, top: 10, left: 20, right: 120, bottom: 60, x: 20, y: 10,
|
||||||
|
} as DOMRect);
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let bounds: ReturnType<typeof useElementBounding>;
|
||||||
|
scope.run(() => {
|
||||||
|
bounds = useElementBounding(ref(el));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(bounds!.width.value).toBe(100);
|
||||||
|
expect(bounds!.height.value).toBe(50);
|
||||||
|
expect(bounds!.top.value).toBe(10);
|
||||||
|
expect(bounds!.left.value).toBe(20);
|
||||||
|
expect(bounds!.x.value).toBe(20);
|
||||||
|
expect(bounds!.y.value).toBe(10);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update recomputes the rect', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
let w = 10;
|
||||||
|
el.getBoundingClientRect = () => ({ width: w, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0 } as DOMRect);
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let bounds: ReturnType<typeof useElementBounding>;
|
||||||
|
scope.run(() => {
|
||||||
|
bounds = useElementBounding(ref(el));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(bounds!.width.value).toBe(10);
|
||||||
|
w = 200;
|
||||||
|
bounds!.update();
|
||||||
|
expect(bounds!.width.value).toBe(200);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets to zero when target is null', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let bounds: ReturnType<typeof useElementBounding>;
|
||||||
|
scope.run(() => {
|
||||||
|
bounds = useElementBounding(ref(null));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(bounds!.width.value).toBe(0);
|
||||||
|
expect(bounds!.height.value).toBe(0);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
// NOTE: defaultWindow is captured at import time, so vi.stubGlobal does not
|
||||||
|
// reach requestAnimationFrame. We inject a fake window via the `window` option.
|
||||||
|
it('defers measurement to the next frame with updateTiming "next-frame"', () => {
|
||||||
|
const raf = vi.fn((cb: FrameRequestCallback) => {
|
||||||
|
cb(0);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
const fakeWindow = { requestAnimationFrame: raf, cancelAnimationFrame: vi.fn() } as unknown as Window;
|
||||||
|
|
||||||
|
const el = document.createElement('div');
|
||||||
|
let w = 10;
|
||||||
|
el.getBoundingClientRect = () => ({ width: w, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0 } as DOMRect);
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let bounds: ReturnType<typeof useElementBounding>;
|
||||||
|
scope.run(() => {
|
||||||
|
bounds = useElementBounding(ref(el), { updateTiming: 'next-frame', window: fakeWindow });
|
||||||
|
});
|
||||||
|
|
||||||
|
// The immediate update went through requestAnimationFrame
|
||||||
|
expect(raf).toHaveBeenCalled();
|
||||||
|
expect(bounds!.width.value).toBe(10);
|
||||||
|
|
||||||
|
w = 200;
|
||||||
|
bounds!.update();
|
||||||
|
expect(bounds!.width.value).toBe(200);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coalesces multiple "next-frame" updates into a single read per frame', () => {
|
||||||
|
let scheduled: FrameRequestCallback | undefined;
|
||||||
|
const raf = vi.fn((cb: FrameRequestCallback) => {
|
||||||
|
scheduled = cb;
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
const fakeWindow = { requestAnimationFrame: raf, cancelAnimationFrame: vi.fn() } as unknown as Window;
|
||||||
|
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const getRect = vi.fn(() => ({ width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0 } as DOMRect));
|
||||||
|
el.getBoundingClientRect = getRect;
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let bounds: ReturnType<typeof useElementBounding>;
|
||||||
|
scope.run(() => {
|
||||||
|
bounds = useElementBounding(ref(el), { updateTiming: 'next-frame', immediate: false, window: fakeWindow });
|
||||||
|
});
|
||||||
|
|
||||||
|
bounds!.update();
|
||||||
|
bounds!.update();
|
||||||
|
bounds!.update();
|
||||||
|
|
||||||
|
// Only one frame was scheduled despite three update() calls
|
||||||
|
expect(raf).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getRect).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Flushing the frame reads the rect exactly once
|
||||||
|
scheduled!(0);
|
||||||
|
expect(getRect).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// A new update after the frame flushed schedules a fresh frame
|
||||||
|
bounds!.update();
|
||||||
|
expect(raf).toHaveBeenCalledTimes(2);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels a pending frame on scope dispose', () => {
|
||||||
|
const raf = vi.fn(() => 42);
|
||||||
|
const caf = vi.fn();
|
||||||
|
const fakeWindow = { requestAnimationFrame: raf, cancelAnimationFrame: caf } as unknown as Window;
|
||||||
|
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.getBoundingClientRect = () => ({ width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0 } as DOMRect);
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
useElementBounding(ref(el), { updateTiming: 'next-frame', window: fakeWindow });
|
||||||
|
});
|
||||||
|
|
||||||
|
// The immediate update scheduled a frame that never ran (raf returns id without invoking)
|
||||||
|
expect(raf).toHaveBeenCalled();
|
||||||
|
scope.stop();
|
||||||
|
expect(caf).toHaveBeenCalledWith(42);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { shallowRef, watch } from 'vue';
|
||||||
|
import type { Ref } from 'vue';
|
||||||
|
import { defaultWindow } from '@/types';
|
||||||
|
import type { ConfigurableWindow } from '@/types';
|
||||||
|
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
||||||
|
import { unrefElement } from '@/composables/component/unrefElement';
|
||||||
|
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||||
|
import { useResizeObserver } from '@/composables/browser/useResizeObserver';
|
||||||
|
import { useMutationObserver } from '@/composables/browser/useMutationObserver';
|
||||||
|
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||||
|
|
||||||
|
export interface UseElementBoundingOptions extends ConfigurableWindow {
|
||||||
|
/**
|
||||||
|
* Reset values to 0 when the element is unmounted
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
reset?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalculate on window resize
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
windowResize?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalculate on window scroll
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
windowScroll?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate immediately on mount
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
immediate?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When to recalculate the bounding box.
|
||||||
|
*
|
||||||
|
* - `'sync'` measures synchronously, the moment a trigger fires.
|
||||||
|
* - `'next-frame'` defers measurement to the next animation frame. This
|
||||||
|
* batches bursts of triggers (e.g. rapid scroll/resize) into a single
|
||||||
|
* read per frame, avoiding repeated layout thrash from `getBoundingClientRect`.
|
||||||
|
*
|
||||||
|
* @default 'sync'
|
||||||
|
*/
|
||||||
|
updateTiming?: 'sync' | 'next-frame';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseElementBoundingReturn {
|
||||||
|
height: Ref<number>;
|
||||||
|
width: Ref<number>;
|
||||||
|
top: Ref<number>;
|
||||||
|
right: Ref<number>;
|
||||||
|
bottom: Ref<number>;
|
||||||
|
left: Ref<number>;
|
||||||
|
x: Ref<number>;
|
||||||
|
y: Ref<number>;
|
||||||
|
/**
|
||||||
|
* Manually recalculate the bounding box, honouring `updateTiming`.
|
||||||
|
*/
|
||||||
|
update: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useElementBounding
|
||||||
|
* @category Browser
|
||||||
|
* @description Reactive bounding box of an element (`getBoundingClientRect`),
|
||||||
|
* kept in sync via `ResizeObserver`, `MutationObserver`, and window scroll/resize.
|
||||||
|
* Supports deferring reads to the next animation frame to avoid layout thrash.
|
||||||
|
*
|
||||||
|
* @param {MaybeComputedElementRef} target Element to measure
|
||||||
|
* @param {UseElementBoundingOptions} [options={}] Options
|
||||||
|
* @returns {UseElementBoundingReturn} Reactive bounds and a manual `update`
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { width, height, top, left } = useElementBounding(el);
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Batch rapid scroll/resize reads into one measurement per frame
|
||||||
|
* const bounds = useElementBounding(el, { updateTiming: 'next-frame' });
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useElementBounding(
|
||||||
|
target: MaybeComputedElementRef,
|
||||||
|
options: UseElementBoundingOptions = {},
|
||||||
|
): UseElementBoundingReturn {
|
||||||
|
const {
|
||||||
|
reset = true,
|
||||||
|
windowResize = true,
|
||||||
|
windowScroll = true,
|
||||||
|
immediate = true,
|
||||||
|
updateTiming = 'sync',
|
||||||
|
window = defaultWindow,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const height = shallowRef(0);
|
||||||
|
const width = shallowRef(0);
|
||||||
|
const top = shallowRef(0);
|
||||||
|
const right = shallowRef(0);
|
||||||
|
const bottom = shallowRef(0);
|
||||||
|
const left = shallowRef(0);
|
||||||
|
const x = shallowRef(0);
|
||||||
|
const y = shallowRef(0);
|
||||||
|
|
||||||
|
function recalculate() {
|
||||||
|
const el = unrefElement(target);
|
||||||
|
|
||||||
|
if (!el) {
|
||||||
|
if (reset) {
|
||||||
|
height.value = 0;
|
||||||
|
width.value = 0;
|
||||||
|
top.value = 0;
|
||||||
|
right.value = 0;
|
||||||
|
bottom.value = 0;
|
||||||
|
left.value = 0;
|
||||||
|
x.value = 0;
|
||||||
|
y.value = 0;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
|
||||||
|
height.value = rect.height;
|
||||||
|
width.value = rect.width;
|
||||||
|
top.value = rect.top;
|
||||||
|
right.value = rect.right;
|
||||||
|
bottom.value = rect.bottom;
|
||||||
|
left.value = rect.left;
|
||||||
|
x.value = rect.x;
|
||||||
|
y.value = rect.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending animation frame id, so deferred reads coalesce and can be cancelled.
|
||||||
|
// `pending` is the source of truth for coalescing; `rafId` is only kept for
|
||||||
|
// cancellation. A separate flag avoids ordering bugs when the scheduler runs
|
||||||
|
// the callback synchronously (the assignment below would otherwise clobber the
|
||||||
|
// id the callback just cleared).
|
||||||
|
let pending = false;
|
||||||
|
let rafId: number | undefined;
|
||||||
|
|
||||||
|
function update() {
|
||||||
|
if (updateTiming === 'next-frame' && window) {
|
||||||
|
// Coalesce: only schedule one read per frame
|
||||||
|
if (pending)
|
||||||
|
return;
|
||||||
|
|
||||||
|
pending = true;
|
||||||
|
rafId = window.requestAnimationFrame(() => {
|
||||||
|
pending = false;
|
||||||
|
rafId = undefined;
|
||||||
|
recalculate();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
recalculate();
|
||||||
|
}
|
||||||
|
|
||||||
|
useResizeObserver(target, update);
|
||||||
|
watch(() => unrefElement(target), el => !el && update());
|
||||||
|
useMutationObserver(target, update, { attributeFilter: ['style', 'class'] });
|
||||||
|
|
||||||
|
if (windowScroll)
|
||||||
|
useEventListener('scroll', update, { capture: true, passive: true });
|
||||||
|
|
||||||
|
if (windowResize)
|
||||||
|
useEventListener('resize', update, { passive: true });
|
||||||
|
|
||||||
|
if (window && immediate)
|
||||||
|
update();
|
||||||
|
|
||||||
|
// Cancel any pending frame so we don't read a detached/disposed element
|
||||||
|
tryOnScopeDispose(() => {
|
||||||
|
if (pending && rafId !== undefined && window)
|
||||||
|
window.cancelAnimationFrame(rafId);
|
||||||
|
|
||||||
|
pending = false;
|
||||||
|
rafId = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
height,
|
||||||
|
width,
|
||||||
|
top,
|
||||||
|
right,
|
||||||
|
bottom,
|
||||||
|
left,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
update,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope, isReadonly, ref } from 'vue';
|
||||||
|
import { useElementHover } from '.';
|
||||||
|
|
||||||
|
function dispatch(el: HTMLElement, type: 'mouseenter' | 'mouseleave') {
|
||||||
|
el.dispatchEvent(new Event(type));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(useElementHover, () => {
|
||||||
|
beforeEach(() => vi.useFakeTimers());
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
it('is false initially', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
let isHovered: ReturnType<typeof useElementHover>;
|
||||||
|
scope.run(() => {
|
||||||
|
isHovered = useElementHover(ref(el));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(isHovered!.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles on mouseenter / mouseleave', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
let isHovered: ReturnType<typeof useElementHover>;
|
||||||
|
scope.run(() => {
|
||||||
|
isHovered = useElementHover(ref(el));
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch(el, 'mouseenter');
|
||||||
|
expect(isHovered!.value).toBeTruthy();
|
||||||
|
|
||||||
|
dispatch(el, 'mouseleave');
|
||||||
|
expect(isHovered!.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a writable shallow ref (not readonly)', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
let isHovered: ReturnType<typeof useElementHover>;
|
||||||
|
scope.run(() => {
|
||||||
|
isHovered = useElementHover(ref(el));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(isReadonly(isHovered!)).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects delayEnter', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
let isHovered: ReturnType<typeof useElementHover>;
|
||||||
|
scope.run(() => {
|
||||||
|
isHovered = useElementHover(ref(el), { delayEnter: 100 });
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch(el, 'mouseenter');
|
||||||
|
expect(isHovered!.value).toBeFalsy();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(99);
|
||||||
|
expect(isHovered!.value).toBeFalsy();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(isHovered!.value).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects delayLeave', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
let isHovered: ReturnType<typeof useElementHover>;
|
||||||
|
scope.run(() => {
|
||||||
|
isHovered = useElementHover(ref(el), { delayLeave: 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch(el, 'mouseenter');
|
||||||
|
expect(isHovered!.value).toBeTruthy();
|
||||||
|
|
||||||
|
dispatch(el, 'mouseleave');
|
||||||
|
expect(isHovered!.value).toBeTruthy();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(199);
|
||||||
|
expect(isHovered!.value).toBeTruthy();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(isHovered!.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels a pending enter timer when leaving before the delay elapses', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
let isHovered: ReturnType<typeof useElementHover>;
|
||||||
|
scope.run(() => {
|
||||||
|
isHovered = useElementHover(ref(el), { delayEnter: 100 });
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch(el, 'mouseenter');
|
||||||
|
vi.advanceTimersByTime(50);
|
||||||
|
expect(isHovered!.value).toBeFalsy();
|
||||||
|
|
||||||
|
// Leaving cancels the pending enter; with no leave delay it settles to false.
|
||||||
|
dispatch(el, 'mouseleave');
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
expect(isHovered!.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels a pending leave timer when re-entering before the delay elapses', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
let isHovered: ReturnType<typeof useElementHover>;
|
||||||
|
scope.run(() => {
|
||||||
|
isHovered = useElementHover(ref(el), { delayLeave: 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch(el, 'mouseenter');
|
||||||
|
expect(isHovered!.value).toBeTruthy();
|
||||||
|
|
||||||
|
dispatch(el, 'mouseleave');
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
// Re-enter before the leave delay finishes: stays hovered, no flip.
|
||||||
|
dispatch(el, 'mouseenter');
|
||||||
|
vi.advanceTimersByTime(200);
|
||||||
|
expect(isHovered!.value).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops listening once the scope is disposed', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
let isHovered: ReturnType<typeof useElementHover>;
|
||||||
|
scope.run(() => {
|
||||||
|
isHovered = useElementHover(ref(el));
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
|
||||||
|
dispatch(el, 'mouseenter');
|
||||||
|
expect(isHovered!.value).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears a pending timer on scope dispose (no late update)', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
let isHovered: ReturnType<typeof useElementHover>;
|
||||||
|
scope.run(() => {
|
||||||
|
isHovered = useElementHover(ref(el), { delayEnter: 100 });
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch(el, 'mouseenter');
|
||||||
|
scope.stop();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(200);
|
||||||
|
expect(isHovered!.value).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a static ref and registers no listeners when window is falsy (SSR)', () => {
|
||||||
|
// `defaultWindow` is captured at import time and cannot be stubbed via
|
||||||
|
// vi.stubGlobal, so we cast a falsy window through options to exercise the
|
||||||
|
// SSR early-return without touching the global. Note that passing literal
|
||||||
|
// `undefined` would resolve back to `defaultWindow` via the destructure
|
||||||
|
// default, hence the explicit null cast here.
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const addSpy = vi.spyOn(el, 'addEventListener');
|
||||||
|
const scope = effectScope();
|
||||||
|
let isHovered: ReturnType<typeof useElementHover>;
|
||||||
|
scope.run(() => {
|
||||||
|
isHovered = useElementHover(ref(el), { window: null as unknown as Window });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(isHovered!.value).toBeFalsy();
|
||||||
|
expect(addSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
dispatch(el, 'mouseenter');
|
||||||
|
expect(isHovered!.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { computed, shallowRef } from 'vue';
|
||||||
|
import type { ShallowRef } from 'vue';
|
||||||
|
import type { ConfigurableWindow } from '@/types';
|
||||||
|
import { defaultWindow } from '@/types';
|
||||||
|
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
||||||
|
import { unrefElement } from '@/composables/component/unrefElement';
|
||||||
|
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||||
|
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||||
|
|
||||||
|
export interface UseElementHoverOptions extends ConfigurableWindow {
|
||||||
|
/**
|
||||||
|
* Delay in milliseconds before flipping the state to hovered on `mouseenter`.
|
||||||
|
*
|
||||||
|
* @default 0
|
||||||
|
*/
|
||||||
|
delayEnter?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delay in milliseconds before flipping the state to not-hovered on `mouseleave`.
|
||||||
|
*
|
||||||
|
* @default 0
|
||||||
|
*/
|
||||||
|
delayLeave?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseElementHoverReturn = ShallowRef<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useElementHover
|
||||||
|
* @category Browser
|
||||||
|
* @description Reactive hover state of an element, driven by `mouseenter` /
|
||||||
|
* `mouseleave`. Supports independent enter/leave delays to debounce flicker.
|
||||||
|
*
|
||||||
|
* @param {MaybeComputedElementRef} target Element to track (ref, getter, or component instance)
|
||||||
|
* @param {UseElementHoverOptions} [options={}] Options (`delayEnter`, `delayLeave`, `window`)
|
||||||
|
* @returns {UseElementHoverReturn} Reactive hover state ref
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const el = useTemplateRef('el');
|
||||||
|
* const isHovered = useElementHover(el);
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const isHovered = useElementHover(el, { delayEnter: 100, delayLeave: 200 });
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useElementHover(
|
||||||
|
target: MaybeComputedElementRef,
|
||||||
|
options: UseElementHoverOptions = {},
|
||||||
|
): UseElementHoverReturn {
|
||||||
|
const {
|
||||||
|
delayEnter = 0,
|
||||||
|
delayLeave = 0,
|
||||||
|
window = defaultWindow,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const isHovered = shallowRef(false);
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
const clear = (): void => {
|
||||||
|
if (timer !== undefined) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = (entering: boolean): void => {
|
||||||
|
const delay = entering ? delayEnter : delayLeave;
|
||||||
|
|
||||||
|
clear();
|
||||||
|
|
||||||
|
if (delay)
|
||||||
|
timer = setTimeout(() => { isHovered.value = entering; }, delay);
|
||||||
|
else
|
||||||
|
isHovered.value = entering;
|
||||||
|
};
|
||||||
|
|
||||||
|
// SSR / no DOM: return a static, never-updating ref.
|
||||||
|
if (!window)
|
||||||
|
return isHovered;
|
||||||
|
|
||||||
|
const targetElement = computed(() => unrefElement(target) as HTMLElement | undefined | null);
|
||||||
|
|
||||||
|
useEventListener(targetElement, 'mouseenter', () => toggle(true), { passive: true });
|
||||||
|
useEventListener(targetElement, 'mouseleave', () => toggle(false), { passive: true });
|
||||||
|
|
||||||
|
tryOnScopeDispose(clear);
|
||||||
|
|
||||||
|
return isHovered;
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope, nextTick, ref } from 'vue';
|
||||||
|
import { useElementSize } from '.';
|
||||||
|
|
||||||
|
interface StubInstance {
|
||||||
|
cb: ResizeObserverCallback;
|
||||||
|
observe: ReturnType<typeof vi.fn>;
|
||||||
|
disconnect: ReturnType<typeof vi.fn>;
|
||||||
|
unobserve: ReturnType<typeof vi.fn>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let instances: StubInstance[] = [];
|
||||||
|
|
||||||
|
class StubResizeObserver {
|
||||||
|
observe = vi.fn();
|
||||||
|
disconnect = vi.fn();
|
||||||
|
unobserve = vi.fn();
|
||||||
|
cb: ResizeObserverCallback;
|
||||||
|
constructor(cb: ResizeObserverCallback) {
|
||||||
|
this.cb = cb;
|
||||||
|
instances.push(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fire(width: number, height: number, fields: Partial<ResizeObserverEntry> = {}) {
|
||||||
|
instances[0]!.cb([
|
||||||
|
{
|
||||||
|
contentBoxSize: [{ inlineSize: width, blockSize: height }],
|
||||||
|
contentRect: { width, height },
|
||||||
|
...fields,
|
||||||
|
} as unknown as ResizeObserverEntry,
|
||||||
|
], {} as ResizeObserver);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(useElementSize, () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
instances = [];
|
||||||
|
vi.stubGlobal('ResizeObserver', StubResizeObserver);
|
||||||
|
});
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('uses the initial size when the target resolves to no element', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let size: ReturnType<typeof useElementSize>;
|
||||||
|
scope.run(() => {
|
||||||
|
size = useElementSize(ref(undefined), { width: 5, height: 7 });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(size!.width.value).toBe(5);
|
||||||
|
expect(size!.height.value).toBe(7);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('measures synchronously on mount via offset size', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
Object.defineProperty(el, 'offsetWidth', { value: 80, configurable: true });
|
||||||
|
Object.defineProperty(el, 'offsetHeight', { value: 60, configurable: true });
|
||||||
|
const scope = effectScope();
|
||||||
|
let size: ReturnType<typeof useElementSize>;
|
||||||
|
scope.run(() => {
|
||||||
|
size = useElementSize(ref(el), { width: 5, height: 7 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// tryOnMounted runs synchronously outside a component, overwriting the initial size.
|
||||||
|
expect(size!.width.value).toBe(80);
|
||||||
|
expect(size!.height.value).toBe(60);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports size from contentBoxSize', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
let size: ReturnType<typeof useElementSize>;
|
||||||
|
scope.run(() => {
|
||||||
|
size = useElementSize(ref(el));
|
||||||
|
});
|
||||||
|
|
||||||
|
instances[0]!.cb([
|
||||||
|
{ contentBoxSize: [{ inlineSize: 100, blockSize: 50 }], contentRect: { width: 0, height: 0 } } as unknown as ResizeObserverEntry,
|
||||||
|
], {} as ResizeObserver);
|
||||||
|
|
||||||
|
expect(size!.width.value).toBe(100);
|
||||||
|
expect(size!.height.value).toBe(50);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to contentRect when box sizes are missing', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
let size: ReturnType<typeof useElementSize>;
|
||||||
|
scope.run(() => {
|
||||||
|
size = useElementSize(ref(el));
|
||||||
|
});
|
||||||
|
|
||||||
|
instances[0]!.cb([
|
||||||
|
{ contentBoxSize: undefined, contentRect: { width: 30, height: 40 } } as unknown as ResizeObserverEntry,
|
||||||
|
], {} as ResizeObserver);
|
||||||
|
|
||||||
|
expect(size!.width.value).toBe(30);
|
||||||
|
expect(size!.height.value).toBe(40);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalises a single (non-array) ResizeObserverSize object', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
let size: ReturnType<typeof useElementSize>;
|
||||||
|
scope.run(() => {
|
||||||
|
size = useElementSize(ref(el));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Older Firefox reports box sizes as a single object rather than an array.
|
||||||
|
instances[0]!.cb([
|
||||||
|
{ contentBoxSize: { inlineSize: 12, blockSize: 34 }, contentRect: { width: 0, height: 0 } } as unknown as ResizeObserverEntry,
|
||||||
|
], {} as ResizeObserver);
|
||||||
|
|
||||||
|
expect(size!.width.value).toBe(12);
|
||||||
|
expect(size!.height.value).toBe(34);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sums multiple box fragments in a single pass', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
let size: ReturnType<typeof useElementSize>;
|
||||||
|
scope.run(() => {
|
||||||
|
size = useElementSize(ref(el));
|
||||||
|
});
|
||||||
|
|
||||||
|
instances[0]!.cb([
|
||||||
|
{
|
||||||
|
contentBoxSize: [
|
||||||
|
{ inlineSize: 10, blockSize: 5 },
|
||||||
|
{ inlineSize: 20, blockSize: 7 },
|
||||||
|
],
|
||||||
|
contentRect: { width: 0, height: 0 },
|
||||||
|
} as unknown as ResizeObserverEntry,
|
||||||
|
], {} as ResizeObserver);
|
||||||
|
|
||||||
|
expect(size!.width.value).toBe(30);
|
||||||
|
expect(size!.height.value).toBe(12);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads borderBoxSize when box is "border-box"', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
let size: ReturnType<typeof useElementSize>;
|
||||||
|
scope.run(() => {
|
||||||
|
size = useElementSize(ref(el), { width: 0, height: 0 }, { box: 'border-box' });
|
||||||
|
});
|
||||||
|
|
||||||
|
instances[0]!.cb([
|
||||||
|
{
|
||||||
|
borderBoxSize: [{ inlineSize: 200, blockSize: 120 }],
|
||||||
|
contentBoxSize: [{ inlineSize: 1, blockSize: 1 }],
|
||||||
|
contentRect: { width: 0, height: 0 },
|
||||||
|
} as unknown as ResizeObserverEntry,
|
||||||
|
], {} as ResizeObserver);
|
||||||
|
|
||||||
|
expect(size!.width.value).toBe(200);
|
||||||
|
expect(size!.height.value).toBe(120);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('measures SVG elements via getBoundingClientRect', () => {
|
||||||
|
const el = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||||
|
el.getBoundingClientRect = () => ({ width: 64, height: 48 }) as DOMRect;
|
||||||
|
const scope = effectScope();
|
||||||
|
let size: ReturnType<typeof useElementSize>;
|
||||||
|
scope.run(() => {
|
||||||
|
size = useElementSize(ref(el), { width: 0, height: 0 }, { window: globalThis as unknown as Window });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Even though the entry advertises a different box size, the SVG path wins.
|
||||||
|
instances[0]!.cb([
|
||||||
|
{ contentBoxSize: [{ inlineSize: 999, blockSize: 999 }], contentRect: { width: 999, height: 999 } } as unknown as ResizeObserverEntry,
|
||||||
|
], {} as ResizeObserver);
|
||||||
|
|
||||||
|
expect(size!.width.value).toBe(64);
|
||||||
|
expect(size!.height.value).toBe(48);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets to 0 when the element detaches', async () => {
|
||||||
|
const el = ref<HTMLElement | undefined>(document.createElement('div'));
|
||||||
|
const scope = effectScope();
|
||||||
|
let size: ReturnType<typeof useElementSize>;
|
||||||
|
scope.run(() => {
|
||||||
|
size = useElementSize(el, { width: 5, height: 7 });
|
||||||
|
});
|
||||||
|
|
||||||
|
fire(100, 50);
|
||||||
|
expect(size!.width.value).toBe(100);
|
||||||
|
|
||||||
|
el.value = undefined;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(size!.width.value).toBe(0);
|
||||||
|
expect(size!.height.value).toBe(0);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stop() disconnects the observer and the detach watcher', async () => {
|
||||||
|
const el = ref<HTMLElement | undefined>(document.createElement('div'));
|
||||||
|
const scope = effectScope();
|
||||||
|
let size: ReturnType<typeof useElementSize>;
|
||||||
|
scope.run(() => {
|
||||||
|
size = useElementSize(el, { width: 0, height: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
expect(instances[0]!.disconnect).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fire(100, 50);
|
||||||
|
expect(size!.width.value).toBe(100);
|
||||||
|
|
||||||
|
size!.stop();
|
||||||
|
// The observer is torn down so it stops delivering callbacks in a real browser.
|
||||||
|
expect(instances[0]!.disconnect).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// The detach watcher is also stopped: clearing the target no longer resets the size to 0.
|
||||||
|
el.value = undefined;
|
||||||
|
await nextTick();
|
||||||
|
expect(size!.width.value).toBe(100);
|
||||||
|
expect(size!.height.value).toBe(50);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { computed, shallowRef, watch } from 'vue';
|
||||||
|
import type { ShallowRef } from 'vue';
|
||||||
|
import { toArray } from '@robonen/stdlib';
|
||||||
|
import type { ConfigurableWindow } from '@/types';
|
||||||
|
import { defaultWindow } from '@/types';
|
||||||
|
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
||||||
|
import { unrefElement } from '@/composables/component/unrefElement';
|
||||||
|
import { useResizeObserver } from '@/composables/browser/useResizeObserver';
|
||||||
|
import type { UseResizeObserverOptions } from '@/composables/browser/useResizeObserver';
|
||||||
|
import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted';
|
||||||
|
|
||||||
|
export interface ElementSize {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseElementSizeOptions extends UseResizeObserverOptions, ConfigurableWindow {}
|
||||||
|
|
||||||
|
export interface UseElementSizeReturn {
|
||||||
|
width: ShallowRef<number>;
|
||||||
|
height: ShallowRef<number>;
|
||||||
|
stop: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useElementSize
|
||||||
|
* @category Browser
|
||||||
|
* @description Reactive size of an element, backed by `ResizeObserver`.
|
||||||
|
* Measures synchronously on mount, handles SVG elements via `getBoundingClientRect`,
|
||||||
|
* and sums multiple box fragments (e.g. multi-column layouts).
|
||||||
|
*
|
||||||
|
* @param {MaybeComputedElementRef} target Element to measure (ref, getter, or component instance)
|
||||||
|
* @param {ElementSize} [initialSize={ width: 0, height: 0 }] Initial size, restored when the element detaches
|
||||||
|
* @param {UseElementSizeOptions} [options={}] Options forwarded to `ResizeObserver` (`box`, `window`)
|
||||||
|
* @returns {UseElementSizeReturn} Reactive `width`, `height`, and a `stop` handle
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const el = useTemplateRef('el');
|
||||||
|
* const { width, height } = useElementSize(el);
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { width, height, stop } = useElementSize(el, { width: 100, height: 100 }, { box: 'border-box' });
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useElementSize(
|
||||||
|
target: MaybeComputedElementRef,
|
||||||
|
initialSize: ElementSize = { width: 0, height: 0 },
|
||||||
|
options: UseElementSizeOptions = {},
|
||||||
|
): UseElementSizeReturn {
|
||||||
|
const { window = defaultWindow, box = 'content-box' } = options;
|
||||||
|
|
||||||
|
const width = shallowRef(initialSize.width);
|
||||||
|
const height = shallowRef(initialSize.height);
|
||||||
|
|
||||||
|
const isSVG = computed(() => unrefElement(target)?.namespaceURI?.includes('svg'));
|
||||||
|
|
||||||
|
const { stop: stopObserver } = useResizeObserver(target, ([entry]) => {
|
||||||
|
if (!entry)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// SVG elements report unreliable box sizes in some browsers; measure the layout box instead.
|
||||||
|
if (window && isSVG.value) {
|
||||||
|
const el = unrefElement(target);
|
||||||
|
if (el) {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
width.value = rect.width;
|
||||||
|
height.value = rect.height;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const boxSize = box === 'border-box'
|
||||||
|
? entry.borderBoxSize
|
||||||
|
: box === 'content-box'
|
||||||
|
? entry.contentBoxSize
|
||||||
|
: entry.devicePixelContentBoxSize;
|
||||||
|
|
||||||
|
if (boxSize) {
|
||||||
|
// Normalise the cross-browser `ResizeObserverSize | ReadonlyArray<ResizeObserverSize>` shape
|
||||||
|
// and sum fragments (e.g. multi-column layouts) in a single pass.
|
||||||
|
let nextWidth = 0;
|
||||||
|
let nextHeight = 0;
|
||||||
|
for (const size of toArray(boxSize as ResizeObserverSize | ResizeObserverSize[])) {
|
||||||
|
nextWidth += size.inlineSize;
|
||||||
|
nextHeight += size.blockSize;
|
||||||
|
}
|
||||||
|
width.value = nextWidth;
|
||||||
|
height.value = nextHeight;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
width.value = entry.contentRect.width;
|
||||||
|
height.value = entry.contentRect.height;
|
||||||
|
}
|
||||||
|
}, options);
|
||||||
|
|
||||||
|
// Provide a measurement immediately on mount, before the first observer callback fires.
|
||||||
|
tryOnMounted(() => {
|
||||||
|
const el = unrefElement(target);
|
||||||
|
if (el) {
|
||||||
|
width.value = 'offsetWidth' in el ? (el as HTMLElement).offsetWidth : initialSize.width;
|
||||||
|
height.value = 'offsetHeight' in el ? (el as HTMLElement).offsetHeight : initialSize.height;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset to the initial size when the element is attached/detached.
|
||||||
|
const stopWatch = watch(
|
||||||
|
() => unrefElement(target),
|
||||||
|
(el) => {
|
||||||
|
width.value = el ? initialSize.width : 0;
|
||||||
|
height.value = el ? initialSize.height : 0;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const stop = (): void => {
|
||||||
|
stopObserver();
|
||||||
|
stopWatch();
|
||||||
|
};
|
||||||
|
|
||||||
|
return { width, height, stop };
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope, isReadonly, ref } from 'vue';
|
||||||
|
import type { UseElementVisibilityReturn } from '.';
|
||||||
|
import { useElementVisibility } from '.';
|
||||||
|
|
||||||
|
let instances: StubIntersectionObserver[] = [];
|
||||||
|
let lastInit: IntersectionObserverInit | undefined;
|
||||||
|
|
||||||
|
class StubIntersectionObserver {
|
||||||
|
observe = vi.fn();
|
||||||
|
disconnect = vi.fn();
|
||||||
|
unobserve = vi.fn();
|
||||||
|
takeRecords = vi.fn();
|
||||||
|
cb: IntersectionObserverCallback;
|
||||||
|
init?: IntersectionObserverInit;
|
||||||
|
constructor(cb: IntersectionObserverCallback, init?: IntersectionObserverInit) {
|
||||||
|
this.cb = cb;
|
||||||
|
this.init = init;
|
||||||
|
lastInit = init;
|
||||||
|
instances.push(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(useElementVisibility, () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
instances = [];
|
||||||
|
lastInit = undefined;
|
||||||
|
vi.stubGlobal('IntersectionObserver', StubIntersectionObserver);
|
||||||
|
});
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('is false initially and updates on intersection', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
let isVisible: UseElementVisibilityReturn<false>;
|
||||||
|
scope.run(() => {
|
||||||
|
isVisible = useElementVisibility(ref(el));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(isVisible!.value).toBeFalsy();
|
||||||
|
|
||||||
|
instances[0]!.cb([{ isIntersecting: true, time: 1 } as IntersectionObserverEntry], {} as IntersectionObserver);
|
||||||
|
expect(isVisible!.value).toBeTruthy();
|
||||||
|
|
||||||
|
instances[0]!.cb([{ isIntersecting: false, time: 2 } as IntersectionObserverEntry], {} as IntersectionObserver);
|
||||||
|
expect(isVisible!.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the most recent entry by time', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
let isVisible: UseElementVisibilityReturn<false>;
|
||||||
|
scope.run(() => {
|
||||||
|
isVisible = useElementVisibility(ref(el));
|
||||||
|
});
|
||||||
|
|
||||||
|
instances[0]!.cb([
|
||||||
|
{ isIntersecting: false, time: 5 } as IntersectionObserverEntry,
|
||||||
|
{ isIntersecting: true, time: 10 } as IntersectionObserverEntry,
|
||||||
|
], {} as IntersectionObserver);
|
||||||
|
expect(isVisible!.value).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects initialValue', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
let isVisible: UseElementVisibilityReturn<false>;
|
||||||
|
scope.run(() => {
|
||||||
|
isVisible = useElementVisibility(ref(el), { initialValue: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(isVisible!.value).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a writable shallow ref (not readonly) by default', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
let isVisible: UseElementVisibilityReturn<false>;
|
||||||
|
scope.run(() => {
|
||||||
|
isVisible = useElementVisibility(ref(el));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(isReadonly(isVisible!)).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards rootMargin and threshold to the observer', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useElementVisibility(ref(el), { rootMargin: '10px', threshold: [0, 0.5, 1] }));
|
||||||
|
|
||||||
|
expect(lastInit?.rootMargin).toBe('10px');
|
||||||
|
expect(lastInit?.threshold).toEqual([0, 0.5, 1]);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops observing after first visibility when once is true', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
let isVisible: UseElementVisibilityReturn<false>;
|
||||||
|
scope.run(() => {
|
||||||
|
isVisible = useElementVisibility(ref(el), { once: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const observer = instances[0]!;
|
||||||
|
|
||||||
|
// Not visible yet: should not disconnect.
|
||||||
|
observer.cb([{ isIntersecting: false, time: 1 } as IntersectionObserverEntry], {} as IntersectionObserver);
|
||||||
|
expect(observer.disconnect).not.toHaveBeenCalled();
|
||||||
|
expect(isVisible!.value).toBeFalsy();
|
||||||
|
|
||||||
|
// Becomes visible: stop() should disconnect the observer.
|
||||||
|
observer.cb([{ isIntersecting: true, time: 2 } as IntersectionObserverEntry], {} as IntersectionObserver);
|
||||||
|
expect(isVisible!.value).toBeTruthy();
|
||||||
|
expect(observer.disconnect).toHaveBeenCalled();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes observer controls when controls is true', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
let result: UseElementVisibilityReturn<true>;
|
||||||
|
scope.run(() => {
|
||||||
|
result = useElementVisibility(ref(el), { controls: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result!).toHaveProperty('isVisible');
|
||||||
|
expect(result!).toHaveProperty('stop');
|
||||||
|
expect(result!).toHaveProperty('pause');
|
||||||
|
expect(result!).toHaveProperty('resume');
|
||||||
|
expect(result!).toHaveProperty('isSupported');
|
||||||
|
expect(result!).toHaveProperty('isActive');
|
||||||
|
|
||||||
|
expect(result!.isVisible.value).toBeFalsy();
|
||||||
|
instances[0]!.cb([{ isIntersecting: true, time: 1 } as IntersectionObserverEntry], {} as IntersectionObserver);
|
||||||
|
expect(result!.isVisible.value).toBeTruthy();
|
||||||
|
|
||||||
|
result!.stop();
|
||||||
|
expect(instances[0]!.disconnect).toHaveBeenCalled();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { shallowRef } from 'vue';
|
||||||
|
import type { ShallowRef } from 'vue';
|
||||||
|
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
||||||
|
import { useIntersectionObserver } from '@/composables/browser/useIntersectionObserver';
|
||||||
|
import type { UseIntersectionObserverOptions, UseIntersectionObserverReturn } from '@/composables/browser/useIntersectionObserver';
|
||||||
|
|
||||||
|
export interface UseElementVisibilityOptions<Controls extends boolean = false> extends UseIntersectionObserverOptions {
|
||||||
|
/**
|
||||||
|
* The initial visibility state, used before the observer reports its first entry.
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
initialValue?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop observing as soon as the element becomes visible for the first time.
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
once?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expose the underlying observer controls (`pause`, `resume`, `stop`, ...)
|
||||||
|
* alongside the visibility ref instead of returning the ref directly.
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
controls?: Controls;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseElementVisibilityReturnWithControls extends UseIntersectionObserverReturn {
|
||||||
|
/**
|
||||||
|
* Whether the element is currently visible within the root/viewport.
|
||||||
|
*/
|
||||||
|
isVisible: ShallowRef<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseElementVisibilityReturn<Controls extends boolean = false>
|
||||||
|
= Controls extends true
|
||||||
|
? UseElementVisibilityReturnWithControls
|
||||||
|
: ShallowRef<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useElementVisibility
|
||||||
|
* @category Browser
|
||||||
|
* @description Track whether an element is visible within the viewport (or a
|
||||||
|
* custom scroll root), backed by `IntersectionObserver`.
|
||||||
|
*
|
||||||
|
* @param {MaybeComputedElementRef} target Element to track
|
||||||
|
* @param {UseElementVisibilityOptions} [options={}] Options
|
||||||
|
* @returns {UseElementVisibilityReturn} Visibility ref, or `{ isVisible, ...controls }` when `controls` is `true`
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const isVisible = useElementVisibility(el);
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { isVisible, stop } = useElementVisibility(el, { controls: true, once: true });
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useElementVisibility(
|
||||||
|
target: MaybeComputedElementRef,
|
||||||
|
options?: UseElementVisibilityOptions<false>,
|
||||||
|
): UseElementVisibilityReturn<false>;
|
||||||
|
export function useElementVisibility(
|
||||||
|
target: MaybeComputedElementRef,
|
||||||
|
options: UseElementVisibilityOptions<true>,
|
||||||
|
): UseElementVisibilityReturn<true>;
|
||||||
|
export function useElementVisibility(
|
||||||
|
target: MaybeComputedElementRef,
|
||||||
|
options: UseElementVisibilityOptions<boolean> = {},
|
||||||
|
): UseElementVisibilityReturn<boolean> {
|
||||||
|
const {
|
||||||
|
initialValue = false,
|
||||||
|
once = false,
|
||||||
|
controls = false,
|
||||||
|
...observerOptions
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const isVisible = shallowRef(initialValue);
|
||||||
|
|
||||||
|
const observer = useIntersectionObserver(target, (entries) => {
|
||||||
|
// Use the most recent entry to reflect the latest state.
|
||||||
|
let latest = isVisible.value;
|
||||||
|
let latestTime = 0;
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.time >= latestTime) {
|
||||||
|
latestTime = entry.time;
|
||||||
|
latest = entry.isIntersecting;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isVisible.value = latest;
|
||||||
|
|
||||||
|
if (once && latest)
|
||||||
|
observer.stop();
|
||||||
|
}, observerOptions);
|
||||||
|
|
||||||
|
if (controls) {
|
||||||
|
return {
|
||||||
|
...observer,
|
||||||
|
isVisible,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return isVisible;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { useEscapeKey } from '.';
|
||||||
|
|
||||||
|
function dispatchEscape() {
|
||||||
|
globalThis.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(useEscapeKey, () => {
|
||||||
|
afterEach(() => {
|
||||||
|
// Ensure no lingering subscribers between tests
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires handler on Escape', () => {
|
||||||
|
const h = vi.fn();
|
||||||
|
const stop = useEscapeKey(h);
|
||||||
|
|
||||||
|
dispatchEscape();
|
||||||
|
expect(h).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores non-Escape keys', () => {
|
||||||
|
const h = vi.fn();
|
||||||
|
const stop = useEscapeKey(h);
|
||||||
|
|
||||||
|
globalThis.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
|
||||||
|
expect(h).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only topmost handler fires when multiple are stacked', () => {
|
||||||
|
const bottom = vi.fn();
|
||||||
|
const top = vi.fn();
|
||||||
|
|
||||||
|
const stopBottom = useEscapeKey(bottom);
|
||||||
|
const stopTop = useEscapeKey(top);
|
||||||
|
|
||||||
|
dispatchEscape();
|
||||||
|
expect(top).toHaveBeenCalledTimes(1);
|
||||||
|
expect(bottom).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
stopTop();
|
||||||
|
dispatchEscape();
|
||||||
|
expect(bottom).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
stopBottom();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stop handle unsubscribes the listener', () => {
|
||||||
|
const h = vi.fn();
|
||||||
|
const stop = useEscapeKey(h);
|
||||||
|
stop();
|
||||||
|
|
||||||
|
dispatchEscape();
|
||||||
|
expect(h).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { VoidFunction } from '@robonen/stdlib';
|
||||||
|
import { noop } from '@robonen/stdlib';
|
||||||
|
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||||
|
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||||
|
import { defaultWindow } from '@/types';
|
||||||
|
|
||||||
|
type EscapeListener = (event: KeyboardEvent) => void;
|
||||||
|
|
||||||
|
// Module-scoped stack: only the topmost non-paused layer handles Escape so that
|
||||||
|
// nested dismissables behave correctly (top-most dialog closes first).
|
||||||
|
const stack: EscapeListener[] = [];
|
||||||
|
let installed = false;
|
||||||
|
let cleanup: VoidFunction = noop;
|
||||||
|
|
||||||
|
function install() {
|
||||||
|
if (installed || !defaultWindow) return;
|
||||||
|
installed = true;
|
||||||
|
|
||||||
|
cleanup = useEventListener(defaultWindow, 'keydown', (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== 'Escape') return;
|
||||||
|
|
||||||
|
const top = stack.at(-1);
|
||||||
|
top?.(event);
|
||||||
|
}, { capture: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function uninstall() {
|
||||||
|
if (!installed || stack.length > 0) return;
|
||||||
|
installed = false;
|
||||||
|
cleanup();
|
||||||
|
cleanup = noop;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useEscapeKey
|
||||||
|
* @category Browser
|
||||||
|
* @description Register a callback for the topmost Escape keydown. Uses an internal
|
||||||
|
* stack so that nested layers (e.g. nested Dialogs) dismiss in the correct order —
|
||||||
|
* only the most recently-registered listener fires for a given keydown.
|
||||||
|
*
|
||||||
|
* @param {(event: KeyboardEvent) => void} handler Callback invoked on the topmost Escape
|
||||||
|
* @returns {VoidFunction} Stop handle that removes the subscription
|
||||||
|
*
|
||||||
|
* @since 0.0.14
|
||||||
|
*/
|
||||||
|
export function useEscapeKey(handler: EscapeListener): VoidFunction {
|
||||||
|
if (!defaultWindow) return noop;
|
||||||
|
|
||||||
|
install();
|
||||||
|
stack.push(handler);
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
const i = stack.lastIndexOf(handler);
|
||||||
|
if (i !== -1) stack.splice(i, 1);
|
||||||
|
uninstall();
|
||||||
|
};
|
||||||
|
|
||||||
|
tryOnScopeDispose(stop);
|
||||||
|
return stop;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { defineComponent, effectScope, nextTick, ref } from 'vue';
|
import { defineComponent, effectScope, nextTick, ref } from 'vue';
|
||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
import { useEventListener } from '.';
|
import { useEventListener } from '.';
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope, nextTick } from 'vue';
|
||||||
|
import { useEyeDropper } from '.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a fake `window` carrying an `EyeDropper` constructor whose `open()`
|
||||||
|
* resolves with the supplied hex. Passed through options so it reaches the
|
||||||
|
* import-time-captured `defaultWindow` substitute (see test gotcha).
|
||||||
|
*/
|
||||||
|
function createWindowWithEyeDropper(hex = '#ff0000') {
|
||||||
|
const open = vi.fn(async (_options?: { signal?: AbortSignal }) => ({ sRGBHex: hex }));
|
||||||
|
|
||||||
|
class EyeDropper {
|
||||||
|
open = open;
|
||||||
|
get [Symbol.toStringTag]() {
|
||||||
|
return 'EyeDropper' as const;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const win = { EyeDropper } as unknown as Window & typeof globalThis;
|
||||||
|
|
||||||
|
return { window: win, open };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(useEyeDropper, () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports supported when EyeDropper exists on window', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
const { window } = createWindowWithEyeDropper();
|
||||||
|
|
||||||
|
let result: ReturnType<typeof useEyeDropper>;
|
||||||
|
scope.run(() => {
|
||||||
|
result = useEyeDropper({ window });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result!.isSupported.value).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports unsupported when EyeDropper is absent', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
const win = {} as unknown as Window & typeof globalThis;
|
||||||
|
|
||||||
|
let result: ReturnType<typeof useEyeDropper>;
|
||||||
|
scope.run(() => {
|
||||||
|
result = useEyeDropper({ window: win });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result!.isSupported.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is SSR safe when window is undefined', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
|
||||||
|
let result: ReturnType<typeof useEyeDropper>;
|
||||||
|
scope.run(() => {
|
||||||
|
result = useEyeDropper({ window: undefined as unknown as Window });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result!.isSupported.value).toBeFalsy();
|
||||||
|
await expect(result!.open()).resolves.toBeUndefined();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults sRGBHex to an empty string', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
const { window } = createWindowWithEyeDropper();
|
||||||
|
|
||||||
|
let result: ReturnType<typeof useEyeDropper>;
|
||||||
|
scope.run(() => {
|
||||||
|
result = useEyeDropper({ window });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result!.sRGBHex.value).toBe('');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors the initialValue option', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
const { window } = createWindowWithEyeDropper();
|
||||||
|
|
||||||
|
let result: ReturnType<typeof useEyeDropper>;
|
||||||
|
scope.run(() => {
|
||||||
|
result = useEyeDropper({ window, initialValue: '#123456' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result!.sRGBHex.value).toBe('#123456');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates sRGBHex and returns the result when open() succeeds', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
const { window, open } = createWindowWithEyeDropper('#00ff00');
|
||||||
|
|
||||||
|
let result: ReturnType<typeof useEyeDropper>;
|
||||||
|
scope.run(() => {
|
||||||
|
result = useEyeDropper({ window });
|
||||||
|
});
|
||||||
|
|
||||||
|
const picked = await result!.open();
|
||||||
|
|
||||||
|
expect(open).toHaveBeenCalledTimes(1);
|
||||||
|
expect(picked).toEqual({ sRGBHex: '#00ff00' });
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
expect(result!.sRGBHex.value).toBe('#00ff00');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards open options (e.g. AbortSignal) to the native open()', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
const { window, open } = createWindowWithEyeDropper();
|
||||||
|
|
||||||
|
let result: ReturnType<typeof useEyeDropper>;
|
||||||
|
scope.run(() => {
|
||||||
|
result = useEyeDropper({ window });
|
||||||
|
});
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
await result!.open({ signal: controller.signal });
|
||||||
|
|
||||||
|
expect(open).toHaveBeenCalledWith({ signal: controller.signal });
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined and does not call the API when unsupported', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
const win = {} as unknown as Window & typeof globalThis;
|
||||||
|
|
||||||
|
let result: ReturnType<typeof useEyeDropper>;
|
||||||
|
scope.run(() => {
|
||||||
|
result = useEyeDropper({ window: win });
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(result!.open()).resolves.toBeUndefined();
|
||||||
|
expect(result!.sRGBHex.value).toBe('');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates rejection when the user cancels the picker', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
const error = new DOMException('aborted', 'AbortError');
|
||||||
|
|
||||||
|
class EyeDropper {
|
||||||
|
open = vi.fn(async () => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
get [Symbol.toStringTag]() {
|
||||||
|
return 'EyeDropper' as const;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const win = { EyeDropper } as unknown as Window & typeof globalThis;
|
||||||
|
|
||||||
|
let result: ReturnType<typeof useEyeDropper>;
|
||||||
|
scope.run(() => {
|
||||||
|
result = useEyeDropper({ window: win });
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(result!.open()).rejects.toThrow(error);
|
||||||
|
expect(result!.sRGBHex.value).toBe('');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { shallowRef } from 'vue';
|
||||||
|
import type { ComputedRef, ShallowRef } from 'vue';
|
||||||
|
import { defaultWindow } from '@/types';
|
||||||
|
import type { ConfigurableWindow } from '@/types';
|
||||||
|
import { useSupported } from '@/composables/browser/useSupported';
|
||||||
|
|
||||||
|
export interface EyeDropperOpenOptions {
|
||||||
|
/**
|
||||||
|
* An `AbortSignal` that can be used to cancel the operation.
|
||||||
|
*
|
||||||
|
* @see https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal
|
||||||
|
*/
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EyeDropperResult {
|
||||||
|
/**
|
||||||
|
* The selected color, in sRGB hexadecimal format (e.g. `#a1b2c3`).
|
||||||
|
*/
|
||||||
|
sRGBHex: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EyeDropper {
|
||||||
|
open: (options?: EyeDropperOpenOptions) => Promise<EyeDropperResult>;
|
||||||
|
[Symbol.toStringTag]: 'EyeDropper';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EyeDropperConstructor = new () => EyeDropper;
|
||||||
|
|
||||||
|
export interface UseEyeDropperOptions extends ConfigurableWindow {
|
||||||
|
/**
|
||||||
|
* Initial `sRGBHex` value before any color has been picked.
|
||||||
|
*
|
||||||
|
* @default ''
|
||||||
|
*/
|
||||||
|
initialValue?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseEyeDropperReturn {
|
||||||
|
/**
|
||||||
|
* Whether the [EyeDropper API](https://developer.mozilla.org/en-US/docs/Web/API/EyeDropper) is supported
|
||||||
|
*/
|
||||||
|
isSupported: ComputedRef<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The most recently picked color, in sRGB hexadecimal format
|
||||||
|
*/
|
||||||
|
sRGBHex: ShallowRef<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the eyedropper and let the user pick a color. Resolves with the
|
||||||
|
* result, or `undefined` when the API is unsupported.
|
||||||
|
*/
|
||||||
|
open: (openOptions?: EyeDropperOpenOptions) => Promise<EyeDropperResult | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useEyeDropper
|
||||||
|
* @category Browser
|
||||||
|
* @description Reactive wrapper around the [EyeDropper API](https://developer.mozilla.org/en-US/docs/Web/API/EyeDropper) for picking colors from the screen.
|
||||||
|
*
|
||||||
|
* @param {UseEyeDropperOptions} [options={}] Options
|
||||||
|
* @returns {UseEyeDropperReturn} `isSupported`, `sRGBHex`, and `open()`
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { isSupported, sRGBHex, open } = useEyeDropper();
|
||||||
|
* if (isSupported.value)
|
||||||
|
* await open();
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useEyeDropper(options: UseEyeDropperOptions = {}): UseEyeDropperReturn {
|
||||||
|
const {
|
||||||
|
window = defaultWindow,
|
||||||
|
initialValue = '',
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const isSupported = useSupported(() => !!window && 'EyeDropper' in window);
|
||||||
|
const sRGBHex = shallowRef(initialValue);
|
||||||
|
|
||||||
|
async function open(openOptions?: EyeDropperOpenOptions): Promise<EyeDropperResult | undefined> {
|
||||||
|
if (!isSupported.value || !window)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const EyeDropperCtor = (window as unknown as { EyeDropper: EyeDropperConstructor }).EyeDropper;
|
||||||
|
const eyeDropper = new EyeDropperCtor();
|
||||||
|
const result = await eyeDropper.open(openOptions);
|
||||||
|
sRGBHex.value = result.sRGBHex;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSupported,
|
||||||
|
sRGBHex,
|
||||||
|
open,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { effectScope, nextTick, ref } from 'vue';
|
||||||
|
import { useFavicon } from '.';
|
||||||
|
|
||||||
|
describe(useFavicon, () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.head.querySelectorAll('link[rel*="icon"]').forEach(el => el.remove());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a link element with the icon href', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useFavicon('/icon.png'));
|
||||||
|
|
||||||
|
const link = document.head.querySelector<HTMLLinkElement>('link[rel*="icon"]');
|
||||||
|
expect(link).not.toBeNull();
|
||||||
|
expect(link!.href).toContain('/icon.png');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the href when the ref changes', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let favicon: ReturnType<typeof useFavicon>;
|
||||||
|
scope.run(() => {
|
||||||
|
favicon = useFavicon('/a.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
favicon!.value = '/b.png';
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const link = document.head.querySelector<HTMLLinkElement>('link[rel*="icon"]');
|
||||||
|
expect(link!.href).toContain('/b.png');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prepends the baseUrl', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useFavicon('icon.png', { baseUrl: 'https://cdn.example.com/' }));
|
||||||
|
|
||||||
|
const link = document.head.querySelector<HTMLLinkElement>('link[rel*="icon"]');
|
||||||
|
expect(link!.href).toBe('https://cdn.example.com/icon.png');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets the MIME type from the file extension', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useFavicon('/icon.svg'));
|
||||||
|
|
||||||
|
const link = document.head.querySelector<HTMLLinkElement>('link[rel*="icon"]');
|
||||||
|
expect(link!.type).toBe('image/svg');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not set a bogus MIME type for query-string or data hrefs', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useFavicon('/icon.png?v=2'));
|
||||||
|
|
||||||
|
const link = document.head.querySelector<HTMLLinkElement>('link[rel*="icon"]');
|
||||||
|
expect(link!.href).toContain('/icon.png?v=2');
|
||||||
|
expect(link!.type).toBe('');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not set a bogus MIME type for extensionless hrefs', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useFavicon('/favicon'));
|
||||||
|
|
||||||
|
const link = document.head.querySelector<HTMLLinkElement>('link[rel*="icon"]');
|
||||||
|
expect(link!.type).toBe('');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks a getter source reactively', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
const dark = ref(false);
|
||||||
|
scope.run(() => useFavicon(() => (dark.value ? '/dark.png' : '/light.png')));
|
||||||
|
|
||||||
|
let link = document.head.querySelector<HTMLLinkElement>('link[rel*="icon"]');
|
||||||
|
expect(link!.href).toContain('/light.png');
|
||||||
|
|
||||||
|
dark.value = true;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
link = document.head.querySelector<HTMLLinkElement>('link[rel*="icon"]');
|
||||||
|
expect(link!.href).toContain('/dark.png');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows an external writable ref passed as source', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
const source = ref('/one.png');
|
||||||
|
let favicon: ReturnType<typeof useFavicon>;
|
||||||
|
scope.run(() => {
|
||||||
|
favicon = useFavicon(source);
|
||||||
|
});
|
||||||
|
|
||||||
|
// returned ref reflects the source
|
||||||
|
expect(favicon!.value).toBe('/one.png');
|
||||||
|
|
||||||
|
source.value = '/two.png';
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const link = document.head.querySelector<HTMLLinkElement>('link[rel*="icon"]');
|
||||||
|
expect(link!.href).toContain('/two.png');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects a custom rel attribute', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useFavicon('/apple.png', { rel: 'apple-touch-icon' }));
|
||||||
|
|
||||||
|
const link = document.head.querySelector<HTMLLinkElement>('link[rel*="apple-touch-icon"]');
|
||||||
|
expect(link).not.toBeNull();
|
||||||
|
expect(link!.href).toContain('/apple.png');
|
||||||
|
link!.remove();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses existing matching link elements instead of creating new ones', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let favicon: ReturnType<typeof useFavicon>;
|
||||||
|
scope.run(() => {
|
||||||
|
favicon = useFavicon('/first.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
favicon!.value = '/second.png';
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const links = document.head.querySelectorAll('link[rel*="icon"]');
|
||||||
|
expect(links).toHaveLength(1);
|
||||||
|
expect((links[0] as HTMLLinkElement).href).toContain('/second.png');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is SSR-safe when no document is available', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
expect(() => {
|
||||||
|
scope.run(() => useFavicon('/icon.png', { document: undefined }));
|
||||||
|
}).not.toThrow();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { toRef, watch } from 'vue';
|
||||||
|
import type { ComputedRef, MaybeRef, MaybeRefOrGetter, Ref } from 'vue';
|
||||||
|
import { isString } from '@robonen/stdlib';
|
||||||
|
import { defaultDocument } from '@/types';
|
||||||
|
import type { ConfigurableDocument } from '@/types';
|
||||||
|
|
||||||
|
export interface UseFaviconOptions extends ConfigurableDocument {
|
||||||
|
/**
|
||||||
|
* Base URL prepended to the icon href
|
||||||
|
*
|
||||||
|
* @default ''
|
||||||
|
*/
|
||||||
|
baseUrl?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `rel` attribute of the favicon link element
|
||||||
|
*
|
||||||
|
* @default 'icon'
|
||||||
|
*/
|
||||||
|
rel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseFaviconReturn = ComputedRef<string | null | undefined> | Ref<string | null | undefined>;
|
||||||
|
|
||||||
|
// Matches a real file extension at the very end of the path portion of the href,
|
||||||
|
// e.g. `/foo.png` -> `png`, but NOT `/foo.png?v=2`, `data:...`, or extensionless hrefs.
|
||||||
|
const FILE_EXTENSION_RE = /\.([a-z0-9]+)$/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useFavicon
|
||||||
|
* @category Browser
|
||||||
|
* @description Reactive favicon.
|
||||||
|
*
|
||||||
|
* @param {MaybeRefOrGetter<string | null | undefined>} [newIcon] Initial icon href. A getter or readonly ref yields a read-only `ComputedRef`; a writable ref or plain value yields a writable `Ref`.
|
||||||
|
* @param {UseFaviconOptions} [options={}] Options
|
||||||
|
* @returns {UseFaviconReturn} A ref bound to the favicon href
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const favicon = useFavicon();
|
||||||
|
* favicon.value = '/new-icon.png';
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Track an existing reactive source (read-only result)
|
||||||
|
* const isDark = useDark();
|
||||||
|
* const favicon = useFavicon(() => isDark.value ? '/dark.png' : '/light.png');
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useFavicon(
|
||||||
|
newIcon: MaybeRefOrGetter<string | null | undefined>,
|
||||||
|
options?: UseFaviconOptions,
|
||||||
|
): ComputedRef<string | null | undefined>;
|
||||||
|
export function useFavicon(
|
||||||
|
newIcon?: MaybeRef<string | null | undefined>,
|
||||||
|
options?: UseFaviconOptions,
|
||||||
|
): Ref<string | null | undefined>;
|
||||||
|
export function useFavicon(
|
||||||
|
newIcon: MaybeRefOrGetter<string | null | undefined> = null,
|
||||||
|
options: UseFaviconOptions = {},
|
||||||
|
): UseFaviconReturn {
|
||||||
|
const {
|
||||||
|
baseUrl = '',
|
||||||
|
rel = 'icon',
|
||||||
|
document = defaultDocument,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const favicon = toRef(newIcon);
|
||||||
|
const selector = `link[rel*="${rel}"]`;
|
||||||
|
|
||||||
|
const applyIcon = (icon: string) => {
|
||||||
|
if (!document)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const href = `${baseUrl}${icon}`;
|
||||||
|
const elements = document.head.querySelectorAll<HTMLLinkElement>(selector);
|
||||||
|
|
||||||
|
if (!elements.length) {
|
||||||
|
const link = document.createElement('link');
|
||||||
|
|
||||||
|
link.rel = rel;
|
||||||
|
link.href = href;
|
||||||
|
|
||||||
|
// Only set a MIME type when the icon actually ends in a file extension;
|
||||||
|
// otherwise we'd emit garbage like `image/png?v=2` or `image/` for
|
||||||
|
// query-string, extensionless, or data: hrefs.
|
||||||
|
const extension = FILE_EXTENSION_RE.exec(icon)?.[1];
|
||||||
|
if (extension)
|
||||||
|
link.type = `image/${extension}`;
|
||||||
|
|
||||||
|
document.head.append(link);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const element of elements)
|
||||||
|
element.href = href;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
favicon,
|
||||||
|
(icon, oldIcon) => {
|
||||||
|
if (isString(icon) && icon !== oldIcon)
|
||||||
|
applyIcon(icon);
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
return favicon;
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope, ref } from 'vue';
|
||||||
|
import { useFileDialog } from '.';
|
||||||
|
|
||||||
|
function makeFile(name = 'a.txt'): File {
|
||||||
|
return new File(['content'], name, { type: 'text/plain' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFileList(files: File[]): FileList {
|
||||||
|
const list = {
|
||||||
|
length: files.length,
|
||||||
|
item: (index: number) => files[index] ?? null,
|
||||||
|
[Symbol.iterator]: () => files[Symbol.iterator](),
|
||||||
|
} as unknown as FileList;
|
||||||
|
files.forEach((file, index) => {
|
||||||
|
(list as unknown as Record<number, File>)[index] = file;
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withScope<T>(fn: () => T): { result: T; stop: () => void } {
|
||||||
|
const scope = effectScope();
|
||||||
|
const result = scope.run(fn)!;
|
||||||
|
return { result, stop: () => scope.stop() };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(useFileDialog, () => {
|
||||||
|
it('exposes files, open, reset, onChange, onCancel', () => {
|
||||||
|
const { result, stop } = withScope(() => useFileDialog());
|
||||||
|
expect(result.files.value).toBeNull();
|
||||||
|
expect(typeof result.open).toBe('function');
|
||||||
|
expect(typeof result.reset).toBe('function');
|
||||||
|
expect(typeof result.onChange).toBe('function');
|
||||||
|
expect(typeof result.onCancel).toBe('function');
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds files from initialFiles (array)', () => {
|
||||||
|
const file = makeFile();
|
||||||
|
const { result, stop } = withScope(() => useFileDialog({ initialFiles: [file] }));
|
||||||
|
expect(result.files.value).not.toBeNull();
|
||||||
|
expect(result.files.value!).toHaveLength(1);
|
||||||
|
expect(result.files.value![0]).toBe(file);
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds files from initialFiles (FileList)', () => {
|
||||||
|
const list = makeFileList([makeFile('x.txt'), makeFile('y.txt')]);
|
||||||
|
const { result, stop } = withScope(() => useFileDialog({ initialFiles: list }));
|
||||||
|
expect(result.files.value!).toHaveLength(2);
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicks the input element when open() is called', () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
const click = vi.spyOn(input, 'click').mockImplementation(() => {});
|
||||||
|
const { result, stop } = withScope(() => useFileDialog({ input }));
|
||||||
|
|
||||||
|
result.open();
|
||||||
|
expect(click).toHaveBeenCalledTimes(1);
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies options to the input element on open()', () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
vi.spyOn(input, 'click').mockImplementation(() => {});
|
||||||
|
const { result, stop } = withScope(() => useFileDialog({
|
||||||
|
input,
|
||||||
|
accept: 'image/*',
|
||||||
|
multiple: false,
|
||||||
|
directory: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
result.open();
|
||||||
|
expect(input.accept).toBe('image/*');
|
||||||
|
expect(input.multiple).toBeFalsy();
|
||||||
|
expect(input.webkitdirectory).toBeTruthy();
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges local options on open(), overriding instance options for that call', () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
vi.spyOn(input, 'click').mockImplementation(() => {});
|
||||||
|
const { result, stop } = withScope(() => useFileDialog({ input, accept: 'image/*' }));
|
||||||
|
|
||||||
|
result.open({ accept: '.pdf', multiple: false });
|
||||||
|
expect(input.accept).toBe('.pdf');
|
||||||
|
expect(input.multiple).toBeFalsy();
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets capture attribute only when provided', () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
vi.spyOn(input, 'click').mockImplementation(() => {});
|
||||||
|
const { result, stop } = withScope(() => useFileDialog({ input, capture: 'user' }));
|
||||||
|
|
||||||
|
result.open();
|
||||||
|
expect(input.capture).toBe('user');
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads reactive options via getters/refs', () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
vi.spyOn(input, 'click').mockImplementation(() => {});
|
||||||
|
const accept = ref('image/*');
|
||||||
|
const { result, stop } = withScope(() => useFileDialog({ input, accept }));
|
||||||
|
|
||||||
|
result.open();
|
||||||
|
expect(input.accept).toBe('image/*');
|
||||||
|
|
||||||
|
accept.value = 'video/*';
|
||||||
|
result.open();
|
||||||
|
expect(input.accept).toBe('video/*');
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates files and triggers onChange when the input changes', () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
const { result, stop } = withScope(() => useFileDialog({ input }));
|
||||||
|
|
||||||
|
const onChange = vi.fn();
|
||||||
|
result.onChange(onChange);
|
||||||
|
|
||||||
|
const list = makeFileList([makeFile()]);
|
||||||
|
// jsdom does not let you assign input.files via real selection, so override the getter.
|
||||||
|
Object.defineProperty(input, 'files', { configurable: true, get: () => list });
|
||||||
|
input.dispatchEvent(new Event('change'));
|
||||||
|
|
||||||
|
expect(result.files.value).toBe(list);
|
||||||
|
expect(result.files.value!).toHaveLength(1);
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onChange).toHaveBeenCalledWith(list);
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('triggers onCancel when the dialog is dismissed', () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
const { result, stop } = withScope(() => useFileDialog({ input }));
|
||||||
|
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
result.onCancel(onCancel);
|
||||||
|
|
||||||
|
input.dispatchEvent(new Event('cancel'));
|
||||||
|
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reset() clears files and fires onChange(null) when the input had a value', () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
// jsdom forbids assigning a non-empty value to a file input, so stub value with a settable getter/setter.
|
||||||
|
let internalValue = 'a.txt';
|
||||||
|
Object.defineProperty(input, 'value', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => internalValue,
|
||||||
|
set: (v: string) => { internalValue = v; },
|
||||||
|
});
|
||||||
|
const { result, stop } = withScope(() => useFileDialog({ input }));
|
||||||
|
|
||||||
|
const onChange = vi.fn();
|
||||||
|
result.onChange(onChange);
|
||||||
|
|
||||||
|
const list = makeFileList([makeFile()]);
|
||||||
|
Object.defineProperty(input, 'files', { configurable: true, get: () => list });
|
||||||
|
input.dispatchEvent(new Event('change'));
|
||||||
|
onChange.mockClear();
|
||||||
|
|
||||||
|
result.reset();
|
||||||
|
expect(result.files.value).toBeNull();
|
||||||
|
expect(input.value).toBe('');
|
||||||
|
expect(onChange).toHaveBeenCalledWith(null);
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('open({ reset: true }) clears the previous selection before opening', () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
vi.spyOn(input, 'click').mockImplementation(() => {});
|
||||||
|
const { result, stop } = withScope(() => useFileDialog({ input }));
|
||||||
|
|
||||||
|
const list = makeFileList([makeFile()]);
|
||||||
|
Object.defineProperty(input, 'files', { configurable: true, get: () => list });
|
||||||
|
input.dispatchEvent(new Event('change'));
|
||||||
|
expect(result.files.value).not.toBeNull();
|
||||||
|
|
||||||
|
result.open({ reset: true });
|
||||||
|
expect(result.files.value).toBeNull();
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('onChange returns an off() that unsubscribes', () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
const { result, stop } = withScope(() => useFileDialog({ input }));
|
||||||
|
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const { off } = result.onChange(onChange);
|
||||||
|
off();
|
||||||
|
|
||||||
|
Object.defineProperty(input, 'files', { configurable: true, value: makeFileList([makeFile()]) });
|
||||||
|
input.dispatchEvent(new Event('change'));
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('files ref is readonly (no input element created in SSR)', () => {
|
||||||
|
// document undefined simulates SSR; no input is created so open() is a no-op.
|
||||||
|
const { result, stop } = withScope(() => useFileDialog({ document: undefined }));
|
||||||
|
expect(result.files.value).toBeNull();
|
||||||
|
expect(() => result.open()).not.toThrow();
|
||||||
|
expect(() => result.reset()).not.toThrow();
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { computed, shallowRef, toValue, watchEffect } from 'vue';
|
||||||
|
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
|
||||||
|
import { defaultDocument } from '@/types';
|
||||||
|
import type { ConfigurableDocument } from '@/types';
|
||||||
|
import { unrefElement } from '@/composables/component/unrefElement';
|
||||||
|
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
||||||
|
|
||||||
|
export interface UseFileDialogOptions extends ConfigurableDocument {
|
||||||
|
/**
|
||||||
|
* Allow selecting multiple files
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
multiple?: MaybeRefOrGetter<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comma-separated list of accepted file types (the input's `accept` attribute)
|
||||||
|
*
|
||||||
|
* @default '*'
|
||||||
|
*/
|
||||||
|
accept?: MaybeRefOrGetter<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hint for which camera/microphone to use on mobile capture (the input's `capture` attribute)
|
||||||
|
*/
|
||||||
|
capture?: MaybeRefOrGetter<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the selected files each time `open()` is called
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
reset?: MaybeRefOrGetter<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select directories instead of files (sets `webkitdirectory`)
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
directory?: MaybeRefOrGetter<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initial files to seed `files` with before any dialog is opened
|
||||||
|
*/
|
||||||
|
initialFiles?: File[] | FileList;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use a custom `<input type="file">` element instead of an internally created one
|
||||||
|
*/
|
||||||
|
input?: MaybeComputedElementRef<HTMLInputElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to an event; returns an unsubscribe function.
|
||||||
|
*/
|
||||||
|
export type FileDialogEventHookOn<T = void> = (callback: (param: T) => void) => { off: () => void };
|
||||||
|
|
||||||
|
export interface UseFileDialogReturn {
|
||||||
|
/**
|
||||||
|
* The currently selected files, or `null` when none are selected
|
||||||
|
*/
|
||||||
|
files: ComputedRef<FileList | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the file dialog, optionally overriding options for this call only
|
||||||
|
*/
|
||||||
|
open: (localOptions?: Partial<UseFileDialogOptions>) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the current selection
|
||||||
|
*/
|
||||||
|
reset: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a callback fired when the selection changes
|
||||||
|
*/
|
||||||
|
onChange: FileDialogEventHookOn<FileList | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a callback fired when the dialog is dismissed without a selection
|
||||||
|
*/
|
||||||
|
onCancel: FileDialogEventHookOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_OPTIONS: UseFileDialogOptions = {
|
||||||
|
multiple: true,
|
||||||
|
accept: '*',
|
||||||
|
reset: false,
|
||||||
|
directory: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface EventHook<T> {
|
||||||
|
on: FileDialogEventHookOn<T>;
|
||||||
|
trigger: (param: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEventHook<T = void>(): EventHook<T> {
|
||||||
|
const callbacks = new Set<(param: T) => void>();
|
||||||
|
|
||||||
|
const on: FileDialogEventHookOn<T> = (callback) => {
|
||||||
|
callbacks.add(callback);
|
||||||
|
return {
|
||||||
|
off: () => {
|
||||||
|
callbacks.delete(callback);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const trigger = (param: T): void => {
|
||||||
|
callbacks.forEach(cb => cb(param));
|
||||||
|
};
|
||||||
|
|
||||||
|
return { on, trigger };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFileList(files: File[] | FileList | undefined): FileList | null {
|
||||||
|
if (!files)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (typeof FileList !== 'undefined' && files instanceof FileList)
|
||||||
|
return files;
|
||||||
|
|
||||||
|
// Materialize a plain array into a FileList via DataTransfer when available.
|
||||||
|
if (typeof DataTransfer !== 'undefined') {
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
for (const file of files)
|
||||||
|
dt.items.add(file);
|
||||||
|
return dt.files;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: build a FileList-like object (environments without DataTransfer, e.g. jsdom).
|
||||||
|
const array = Array.from(files);
|
||||||
|
const list = {
|
||||||
|
length: array.length,
|
||||||
|
item: (index: number) => array[index] ?? null,
|
||||||
|
[Symbol.iterator]: () => array[Symbol.iterator](),
|
||||||
|
} as unknown as FileList;
|
||||||
|
array.forEach((file, index) => {
|
||||||
|
(list as unknown as Record<number, File>)[index] = file;
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useFileDialog
|
||||||
|
* @category Browser
|
||||||
|
* @description Open a native file dialog programmatically and reactively track the selected files.
|
||||||
|
*
|
||||||
|
* @param {UseFileDialogOptions} [options={}] Options
|
||||||
|
* @returns {UseFileDialogReturn} `files`, `open`, `reset`, `onChange`, and `onCancel`
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { files, open, onChange } = useFileDialog({ accept: 'image/*' });
|
||||||
|
* onChange((selected) => console.log(selected));
|
||||||
|
* open();
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Override options for a single call
|
||||||
|
* const { open } = useFileDialog();
|
||||||
|
* open({ multiple: false, accept: '.pdf' });
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useFileDialog(options: UseFileDialogOptions = {}): UseFileDialogReturn {
|
||||||
|
const {
|
||||||
|
document = defaultDocument,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const files = shallowRef<FileList | null>(toFileList(options.initialFiles));
|
||||||
|
|
||||||
|
const { on: onChange, trigger: changeTrigger } = createEventHook<FileList | null>();
|
||||||
|
const { on: onCancel, trigger: cancelTrigger } = createEventHook();
|
||||||
|
|
||||||
|
const inputRef = shallowRef<HTMLInputElement | undefined>();
|
||||||
|
|
||||||
|
// Eagerly resolve the input element (custom or internally created) and wire its
|
||||||
|
// handlers, re-running if a reactive `options.input` target changes.
|
||||||
|
watchEffect(() => {
|
||||||
|
const input = unrefElement(options.input)
|
||||||
|
?? (document ? document.createElement('input') : undefined);
|
||||||
|
|
||||||
|
if (input) {
|
||||||
|
input.type = 'file';
|
||||||
|
input.onchange = (event: Event) => {
|
||||||
|
const result = event.target as HTMLInputElement;
|
||||||
|
files.value = result.files;
|
||||||
|
changeTrigger(files.value);
|
||||||
|
};
|
||||||
|
input.oncancel = () => {
|
||||||
|
cancelTrigger();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
inputRef.value = input;
|
||||||
|
});
|
||||||
|
|
||||||
|
const reset = (): void => {
|
||||||
|
files.value = null;
|
||||||
|
const el = inputRef.value;
|
||||||
|
if (el && el.value) {
|
||||||
|
el.value = '';
|
||||||
|
changeTrigger(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyOptions = (opts: UseFileDialogOptions): void => {
|
||||||
|
const el = inputRef.value;
|
||||||
|
if (!el)
|
||||||
|
return;
|
||||||
|
|
||||||
|
el.multiple = toValue(opts.multiple)!;
|
||||||
|
el.accept = toValue(opts.accept)!;
|
||||||
|
el.webkitdirectory = toValue(opts.directory)!;
|
||||||
|
if ('capture' in opts)
|
||||||
|
el.capture = toValue(opts.capture)!;
|
||||||
|
};
|
||||||
|
|
||||||
|
const open = (localOptions?: Partial<UseFileDialogOptions>): void => {
|
||||||
|
const el = inputRef.value;
|
||||||
|
if (!el)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const mergedOptions: UseFileDialogOptions = {
|
||||||
|
...DEFAULT_OPTIONS,
|
||||||
|
...options,
|
||||||
|
...localOptions,
|
||||||
|
};
|
||||||
|
|
||||||
|
applyOptions(mergedOptions);
|
||||||
|
|
||||||
|
if (toValue(mergedOptions.reset))
|
||||||
|
reset();
|
||||||
|
|
||||||
|
el.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
files: computed(() => files.value),
|
||||||
|
open,
|
||||||
|
reset,
|
||||||
|
onChange,
|
||||||
|
onCancel,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { effectScope, nextTick, ref } from 'vue';
|
||||||
|
import { useFocus } from '.';
|
||||||
|
|
||||||
|
function host<T>(fn: () => T): { result: T; stop: () => void } {
|
||||||
|
const scope = effectScope();
|
||||||
|
let result!: T;
|
||||||
|
scope.run(() => {
|
||||||
|
result = fn();
|
||||||
|
});
|
||||||
|
return { result, stop: () => scope.stop() };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(useFocus, () => {
|
||||||
|
it('reflects focus and blur events on the target', async () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
document.body.appendChild(input);
|
||||||
|
|
||||||
|
const { result, stop } = host(() => useFocus(input));
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(result.focused.value).toBeFalsy();
|
||||||
|
|
||||||
|
input.dispatchEvent(new FocusEvent('focus'));
|
||||||
|
expect(result.focused.value).toBeTruthy();
|
||||||
|
|
||||||
|
input.dispatchEvent(new FocusEvent('blur'));
|
||||||
|
expect(result.focused.value).toBeFalsy();
|
||||||
|
|
||||||
|
stop();
|
||||||
|
input.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('focuses the element when writing true', async () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
document.body.appendChild(input);
|
||||||
|
|
||||||
|
const { result, stop } = host(() => useFocus(input));
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
result.focused.value = true;
|
||||||
|
// jsdom dispatches the focus event synchronously from .focus()
|
||||||
|
expect(document.activeElement).toBe(input);
|
||||||
|
expect(result.focused.value).toBeTruthy();
|
||||||
|
|
||||||
|
stop();
|
||||||
|
input.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blurs the element when writing false', async () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
document.body.appendChild(input);
|
||||||
|
|
||||||
|
const { result, stop } = host(() => useFocus(input));
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
result.focused.value = true;
|
||||||
|
expect(document.activeElement).toBe(input);
|
||||||
|
|
||||||
|
result.focused.value = false;
|
||||||
|
expect(document.activeElement).not.toBe(input);
|
||||||
|
expect(result.focused.value).toBeFalsy();
|
||||||
|
|
||||||
|
stop();
|
||||||
|
input.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('focuses on mount when initialValue is true', async () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
document.body.appendChild(input);
|
||||||
|
|
||||||
|
const { result, stop } = host(() => useFocus(input, { initialValue: true }));
|
||||||
|
// the watch runs with flush: 'post'
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(input);
|
||||||
|
expect(result.focused.value).toBeTruthy();
|
||||||
|
|
||||||
|
stop();
|
||||||
|
input.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes preventScroll to focus()', async () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
document.body.appendChild(input);
|
||||||
|
let receivedOptions: FocusOptions | undefined;
|
||||||
|
const originalFocus = input.focus.bind(input);
|
||||||
|
input.focus = (opts?: FocusOptions) => {
|
||||||
|
receivedOptions = opts;
|
||||||
|
originalFocus(opts);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { result, stop } = host(() => useFocus(input, { preventScroll: true }));
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
result.focused.value = true;
|
||||||
|
expect(receivedOptions).toEqual({ preventScroll: true });
|
||||||
|
|
||||||
|
stop();
|
||||||
|
input.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects focusVisible by checking :focus-visible', async () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
document.body.appendChild(input);
|
||||||
|
// emulate the element NOT matching :focus-visible (e.g. mouse focus)
|
||||||
|
input.matches = () => false;
|
||||||
|
|
||||||
|
const { result, stop } = host(() => useFocus(input, { focusVisible: true }));
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
input.dispatchEvent(new FocusEvent('focus'));
|
||||||
|
// focus should be ignored because :focus-visible did not match
|
||||||
|
expect(result.focused.value).toBeFalsy();
|
||||||
|
|
||||||
|
// now emulate a keyboard focus that does match
|
||||||
|
input.matches = (selector: string) => selector === ':focus-visible';
|
||||||
|
input.dispatchEvent(new FocusEvent('focus'));
|
||||||
|
expect(result.focused.value).toBeTruthy();
|
||||||
|
|
||||||
|
stop();
|
||||||
|
input.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks a reactive (changing) target', async () => {
|
||||||
|
const a = document.createElement('input');
|
||||||
|
const b = document.createElement('input');
|
||||||
|
document.body.append(a, b);
|
||||||
|
|
||||||
|
const target = ref<HTMLElement>(a);
|
||||||
|
const { result, stop } = host(() => useFocus(target));
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
// start unfocused, then focus the first target
|
||||||
|
a.dispatchEvent(new FocusEvent('focus'));
|
||||||
|
expect(result.focused.value).toBeTruthy();
|
||||||
|
a.dispatchEvent(new FocusEvent('blur'));
|
||||||
|
expect(result.focused.value).toBeFalsy();
|
||||||
|
|
||||||
|
// swap the tracked target; listeners follow the reactive ref
|
||||||
|
target.value = b;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
// events on the new element are now tracked
|
||||||
|
b.dispatchEvent(new FocusEvent('focus'));
|
||||||
|
expect(result.focused.value).toBeTruthy();
|
||||||
|
b.dispatchEvent(new FocusEvent('blur'));
|
||||||
|
expect(result.focused.value).toBeFalsy();
|
||||||
|
|
||||||
|
// events on the old element no longer affect state
|
||||||
|
a.dispatchEvent(new FocusEvent('focus'));
|
||||||
|
expect(result.focused.value).toBeFalsy();
|
||||||
|
|
||||||
|
stop();
|
||||||
|
a.remove();
|
||||||
|
b.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw when the target is null', async () => {
|
||||||
|
const target = ref<HTMLElement | null>(null);
|
||||||
|
const { result, stop } = host(() => useFocus(target));
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(result.focused.value).toBeFalsy();
|
||||||
|
// writing to a null target must be a no-op, not a crash
|
||||||
|
expect(() => {
|
||||||
|
result.focused.value = true;
|
||||||
|
}).not.toThrow();
|
||||||
|
expect(result.focused.value).toBeFalsy();
|
||||||
|
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { computed, shallowRef, watch } from 'vue';
|
||||||
|
import type { WritableComputedRef } from 'vue';
|
||||||
|
import { unrefElement } from '@/composables/component/unrefElement';
|
||||||
|
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
||||||
|
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||||
|
import type { ConfigurableWindow } from '@/types';
|
||||||
|
|
||||||
|
export interface UseFocusOptions extends ConfigurableWindow {
|
||||||
|
/**
|
||||||
|
* Initial focus state. When `true`, the element is focused on mount.
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
initialValue?: boolean;
|
||||||
|
/**
|
||||||
|
* Only consider the element focused when it matches `:focus-visible`,
|
||||||
|
* mirroring the browser's keyboard-focus heuristics.
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
focusVisible?: boolean;
|
||||||
|
/**
|
||||||
|
* Prevent the browser from scrolling the element into view when focusing it.
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
preventScroll?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseFocusReturn {
|
||||||
|
/**
|
||||||
|
* Reactive focus state. Read it to know whether the target is focused, or
|
||||||
|
* write to it to programmatically focus (`true`) or blur (`false`) the target.
|
||||||
|
*/
|
||||||
|
focused: WritableComputedRef<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useFocus
|
||||||
|
* @category Browser
|
||||||
|
* @description Reactive focus state of an element. The returned `focused` ref tracks
|
||||||
|
* focus/blur events and can be written to in order to focus or blur the target.
|
||||||
|
*
|
||||||
|
* @param {MaybeComputedElementRef} target - The element (or template ref) to track.
|
||||||
|
* @param {UseFocusOptions} [options={}] - Options
|
||||||
|
* @returns {UseFocusReturn} An object containing the writable `focused` ref.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const el = useTemplateRef<HTMLInputElement>('el');
|
||||||
|
* const { focused } = useFocus(el);
|
||||||
|
* // focus the element imperatively
|
||||||
|
* focused.value = true;
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // only treat keyboard focus as focused
|
||||||
|
* const { focused } = useFocus(el, { focusVisible: true });
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useFocus(
|
||||||
|
target: MaybeComputedElementRef,
|
||||||
|
options: UseFocusOptions = {},
|
||||||
|
): UseFocusReturn {
|
||||||
|
const {
|
||||||
|
initialValue = false,
|
||||||
|
focusVisible = false,
|
||||||
|
preventScroll = false,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const innerFocused = shallowRef(false);
|
||||||
|
const targetElement = computed(() => unrefElement(target) as HTMLElement | undefined | null);
|
||||||
|
|
||||||
|
const listenerOptions = { passive: true } as const;
|
||||||
|
|
||||||
|
useEventListener(
|
||||||
|
targetElement,
|
||||||
|
'focus',
|
||||||
|
(event: FocusEvent) => {
|
||||||
|
if (!focusVisible || (event.target as HTMLElement).matches?.(':focus-visible'))
|
||||||
|
innerFocused.value = true;
|
||||||
|
},
|
||||||
|
listenerOptions,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEventListener(
|
||||||
|
targetElement,
|
||||||
|
'blur',
|
||||||
|
() => {
|
||||||
|
innerFocused.value = false;
|
||||||
|
},
|
||||||
|
listenerOptions,
|
||||||
|
);
|
||||||
|
|
||||||
|
const focused = computed<boolean>({
|
||||||
|
get: () => innerFocused.value,
|
||||||
|
set(value: boolean) {
|
||||||
|
if (!value && innerFocused.value)
|
||||||
|
targetElement.value?.blur();
|
||||||
|
else if (value && !innerFocused.value)
|
||||||
|
targetElement.value?.focus({ preventScroll });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
targetElement,
|
||||||
|
() => {
|
||||||
|
focused.value = initialValue;
|
||||||
|
},
|
||||||
|
{ immediate: true, flush: 'post' },
|
||||||
|
);
|
||||||
|
|
||||||
|
return { focused };
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, beforeEach, afterEach, expect } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
import { defineComponent, nextTick } from 'vue';
|
import { defineComponent, nextTick } from 'vue';
|
||||||
import { useFocusGuard } from '.';
|
import { useFocusGuard } from '.';
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { effectScope, nextTick, shallowRef } from 'vue';
|
||||||
|
import { useFocusWithin } from '.';
|
||||||
|
|
||||||
|
function makeTree(): { container: HTMLDivElement; input: HTMLInputElement; outside: HTMLButtonElement } {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
const input = document.createElement('input');
|
||||||
|
container.appendChild(input);
|
||||||
|
|
||||||
|
const outside = document.createElement('button');
|
||||||
|
|
||||||
|
document.body.appendChild(container);
|
||||||
|
document.body.appendChild(outside);
|
||||||
|
|
||||||
|
return { container, input, outside };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(useFocusWithin, () => {
|
||||||
|
it('is not focused initially when nothing inside has focus', () => {
|
||||||
|
const { container } = makeTree();
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let result: ReturnType<typeof useFocusWithin>;
|
||||||
|
scope.run(() => {
|
||||||
|
result = useFocusWithin(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result!.focused.value).toBeFalsy();
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
document.body.replaceChildren();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('becomes focused when a descendant receives focus', async () => {
|
||||||
|
const { container, input } = makeTree();
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let result: ReturnType<typeof useFocusWithin>;
|
||||||
|
scope.run(() => {
|
||||||
|
result = useFocusWithin(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
input.focus();
|
||||||
|
container.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(result!.focused.value).toBeTruthy();
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
document.body.replaceChildren();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is focused when the target element itself receives focus', async () => {
|
||||||
|
const { container } = makeTree();
|
||||||
|
container.tabIndex = 0;
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let result: ReturnType<typeof useFocusWithin>;
|
||||||
|
scope.run(() => {
|
||||||
|
result = useFocusWithin(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.focus();
|
||||||
|
container.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(result!.focused.value).toBeTruthy();
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
document.body.replaceChildren();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears focus when focus leaves the element entirely', async () => {
|
||||||
|
const { container, input, outside } = makeTree();
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let result: ReturnType<typeof useFocusWithin>;
|
||||||
|
scope.run(() => {
|
||||||
|
result = useFocusWithin(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
input.focus();
|
||||||
|
container.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||||
|
await nextTick();
|
||||||
|
expect(result!.focused.value).toBeTruthy();
|
||||||
|
|
||||||
|
// Move focus to an element outside the container.
|
||||||
|
outside.focus();
|
||||||
|
container.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: outside }));
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(result!.focused.value).toBeFalsy();
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
document.body.replaceChildren();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays focused when focus moves between descendants', async () => {
|
||||||
|
const { container, input } = makeTree();
|
||||||
|
const second = document.createElement('input');
|
||||||
|
container.appendChild(second);
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let result: ReturnType<typeof useFocusWithin>;
|
||||||
|
scope.run(() => {
|
||||||
|
result = useFocusWithin(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
input.focus();
|
||||||
|
container.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||||
|
await nextTick();
|
||||||
|
expect(result!.focused.value).toBeTruthy();
|
||||||
|
|
||||||
|
// focusout fires as focus shifts, but the second input is still inside
|
||||||
|
// the container, so `:focus-within` keeps the state true.
|
||||||
|
second.focus();
|
||||||
|
container.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: second }));
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(result!.focused.value).toBeTruthy();
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
document.body.replaceChildren();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reflects focus that already lives inside the target on creation', () => {
|
||||||
|
const { container, input } = makeTree();
|
||||||
|
input.focus();
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let result: ReturnType<typeof useFocusWithin>;
|
||||||
|
scope.run(() => {
|
||||||
|
result = useFocusWithin(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result!.focused.value).toBeTruthy();
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
document.body.replaceChildren();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a reactive ref target', async () => {
|
||||||
|
const { container, input } = makeTree();
|
||||||
|
const targetRef = shallowRef<HTMLElement | null>(container);
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let result: ReturnType<typeof useFocusWithin>;
|
||||||
|
scope.run(() => {
|
||||||
|
result = useFocusWithin(targetRef);
|
||||||
|
});
|
||||||
|
|
||||||
|
input.focus();
|
||||||
|
container.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(result!.focused.value).toBeTruthy();
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
document.body.replaceChildren();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes a read-only computed (write throws / has no setter)', () => {
|
||||||
|
const { container } = makeTree();
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let result: ReturnType<typeof useFocusWithin>;
|
||||||
|
scope.run(() => {
|
||||||
|
result = useFocusWithin(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
// computed without a setter ignores writes (does not mutate internal state)
|
||||||
|
// @ts-expect-error - intentionally writing to a read-only computed
|
||||||
|
result!.focused.value = true;
|
||||||
|
expect(result!.focused.value).toBeFalsy();
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
document.body.replaceChildren();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw and stays false when window is unavailable (SSR)', () => {
|
||||||
|
const { container } = makeTree();
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let result: ReturnType<typeof useFocusWithin>;
|
||||||
|
scope.run(() => {
|
||||||
|
result = useFocusWithin(container, { window: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result!.focused.value).toBeFalsy();
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
document.body.replaceChildren();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { computed, shallowRef } from 'vue';
|
||||||
|
import type { ComputedRef } from 'vue';
|
||||||
|
import { defaultWindow } from '@/types';
|
||||||
|
import type { ConfigurableWindow } from '@/types';
|
||||||
|
import { unrefElement } from '@/composables/component/unrefElement';
|
||||||
|
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
||||||
|
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||||
|
|
||||||
|
export interface UseFocusWithinOptions extends ConfigurableWindow {}
|
||||||
|
|
||||||
|
export interface UseFocusWithinReturn {
|
||||||
|
/**
|
||||||
|
* Whether the element or any of its descendants currently hold focus.
|
||||||
|
*/
|
||||||
|
focused: ComputedRef<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useFocusWithin
|
||||||
|
* @category Browser
|
||||||
|
* @description Reactive tracking of whether an element or any of its
|
||||||
|
* descendants are focused, backed by the `focusin`/`focusout` events.
|
||||||
|
*
|
||||||
|
* @param {MaybeComputedElementRef} target Element to track
|
||||||
|
* @param {UseFocusWithinOptions} [options={}] Options
|
||||||
|
* @returns {UseFocusWithinReturn} `{ focused }` reactive focus-within state
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const el = useTemplateRef<HTMLElement>('el');
|
||||||
|
* const { focused } = useFocusWithin(el);
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useFocusWithin(
|
||||||
|
target: MaybeComputedElementRef,
|
||||||
|
options: UseFocusWithinOptions = {},
|
||||||
|
): UseFocusWithinReturn {
|
||||||
|
const { window = defaultWindow } = options;
|
||||||
|
|
||||||
|
const _focused = shallowRef(false);
|
||||||
|
const focused = computed(() => _focused.value);
|
||||||
|
|
||||||
|
const activeElement = window?.document?.activeElement;
|
||||||
|
|
||||||
|
const targetElement = computed(() => unrefElement(target) as HTMLElement | undefined | null);
|
||||||
|
|
||||||
|
if (window) {
|
||||||
|
useEventListener(targetElement, 'focusin', () => {
|
||||||
|
_focused.value = true;
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
useEventListener(targetElement, 'focusout', (event: FocusEvent) => {
|
||||||
|
// After focus leaves a descendant, confirm focus did not simply move to
|
||||||
|
// another descendant. `event.relatedTarget` carries the element about to
|
||||||
|
// receive focus; if it is still inside `target` we remain focused. We
|
||||||
|
// also consult the `:focus-within` pseudo-class as a fallback for cases
|
||||||
|
// where `relatedTarget` is unavailable.
|
||||||
|
const el = unrefElement(target);
|
||||||
|
|
||||||
|
if (!el) {
|
||||||
|
_focused.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = event.relatedTarget as Node | null;
|
||||||
|
if (next && el.contains(next)) {
|
||||||
|
_focused.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_focused.value = el.matches?.(':focus-within') ?? false;
|
||||||
|
}, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reflect focus that already lives inside the target on initialization.
|
||||||
|
const el = unrefElement(target);
|
||||||
|
if (el && activeElement && (el === activeElement || el.contains(activeElement)))
|
||||||
|
_focused.value = true;
|
||||||
|
|
||||||
|
return {
|
||||||
|
focused,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { effectScope } from 'vue';
|
import { effectScope } from 'vue';
|
||||||
import { useFps } from '.';
|
import { useFps } from '.';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope, nextTick, shallowRef } from 'vue';
|
||||||
|
import { useFullscreen } from '.';
|
||||||
|
|
||||||
|
type Listener = (ev: Event) => void;
|
||||||
|
|
||||||
|
interface FakeEl {
|
||||||
|
requestFullscreen: ReturnType<typeof vi.fn>;
|
||||||
|
addEventListener: ReturnType<typeof vi.fn>;
|
||||||
|
removeEventListener: ReturnType<typeof vi.fn>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FakeDoc {
|
||||||
|
documentElement: FakeEl;
|
||||||
|
exitFullscreen: ReturnType<typeof vi.fn>;
|
||||||
|
fullscreenElement: Element | null;
|
||||||
|
fullScreen: boolean;
|
||||||
|
addEventListener: (event: string, cb: Listener) => void;
|
||||||
|
removeEventListener: (event: string, cb: Listener) => void;
|
||||||
|
dispatch: (event: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFakeElement(): FakeEl {
|
||||||
|
return {
|
||||||
|
requestFullscreen: vi.fn(async () => {}),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFakeDocument(el?: FakeEl): FakeDoc {
|
||||||
|
const listeners = new Map<string, Set<Listener>>();
|
||||||
|
const documentElement = el ?? createFakeElement();
|
||||||
|
|
||||||
|
const doc: FakeDoc = {
|
||||||
|
documentElement,
|
||||||
|
exitFullscreen: vi.fn(async () => {}),
|
||||||
|
fullscreenElement: null,
|
||||||
|
fullScreen: false,
|
||||||
|
addEventListener(event, cb) {
|
||||||
|
if (!listeners.has(event))
|
||||||
|
listeners.set(event, new Set());
|
||||||
|
listeners.get(event)!.add(cb);
|
||||||
|
},
|
||||||
|
removeEventListener(event, cb) {
|
||||||
|
listeners.get(event)?.delete(cb);
|
||||||
|
},
|
||||||
|
dispatch(event) {
|
||||||
|
listeners.get(event)?.forEach(cb => cb(new Event(event)));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(useFullscreen, () => {
|
||||||
|
it('reports support when request/exit/flag methods exist', () => {
|
||||||
|
const document = createFakeDocument();
|
||||||
|
const scope = effectScope();
|
||||||
|
let fs: ReturnType<typeof useFullscreen>;
|
||||||
|
scope.run(() => {
|
||||||
|
fs = useFullscreen(undefined, { document: document as unknown as Document });
|
||||||
|
});
|
||||||
|
expect(fs!.isSupported.value).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not supported when the Fullscreen API is absent (SSR/unsupported)', () => {
|
||||||
|
const document = {
|
||||||
|
documentElement: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
} as unknown as Document;
|
||||||
|
const scope = effectScope();
|
||||||
|
let fs: ReturnType<typeof useFullscreen>;
|
||||||
|
scope.run(() => {
|
||||||
|
fs = useFullscreen(undefined, { document });
|
||||||
|
});
|
||||||
|
expect(fs!.isSupported.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not supported when no document is available (SSR)', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let fs: ReturnType<typeof useFullscreen>;
|
||||||
|
// No document and no defaultDocument in jsdom-less branch — pass an explicit undefined.
|
||||||
|
scope.run(() => {
|
||||||
|
fs = useFullscreen(undefined, { document: undefined });
|
||||||
|
});
|
||||||
|
expect(fs!.isSupported.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts not fullscreen', () => {
|
||||||
|
const document = createFakeDocument();
|
||||||
|
const scope = effectScope();
|
||||||
|
let fs: ReturnType<typeof useFullscreen>;
|
||||||
|
scope.run(() => {
|
||||||
|
fs = useFullscreen(undefined, { document: document as unknown as Document });
|
||||||
|
});
|
||||||
|
expect(fs!.isFullscreen.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enter() requests fullscreen on the target element and sets the flag', async () => {
|
||||||
|
const el = createFakeElement();
|
||||||
|
const document = createFakeDocument(el);
|
||||||
|
const scope = effectScope();
|
||||||
|
let fs: ReturnType<typeof useFullscreen>;
|
||||||
|
scope.run(() => {
|
||||||
|
fs = useFullscreen(undefined, { document: document as unknown as Document });
|
||||||
|
});
|
||||||
|
|
||||||
|
await fs!.enter();
|
||||||
|
expect(el.requestFullscreen).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fs!.isFullscreen.value).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enter() requests fullscreen on a provided target element', async () => {
|
||||||
|
const target = createFakeElement();
|
||||||
|
const document = createFakeDocument();
|
||||||
|
const scope = effectScope();
|
||||||
|
let fs: ReturnType<typeof useFullscreen>;
|
||||||
|
scope.run(() => {
|
||||||
|
fs = useFullscreen(target as unknown as HTMLElement, { document: document as unknown as Document });
|
||||||
|
});
|
||||||
|
|
||||||
|
await fs!.enter();
|
||||||
|
expect(target.requestFullscreen).toHaveBeenCalledTimes(1);
|
||||||
|
expect(document.documentElement.requestFullscreen).not.toHaveBeenCalled();
|
||||||
|
expect(fs!.isFullscreen.value).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enter() is a no-op when already fullscreen', async () => {
|
||||||
|
const el = createFakeElement();
|
||||||
|
const document = createFakeDocument(el);
|
||||||
|
const scope = effectScope();
|
||||||
|
let fs: ReturnType<typeof useFullscreen>;
|
||||||
|
scope.run(() => {
|
||||||
|
fs = useFullscreen(undefined, { document: document as unknown as Document });
|
||||||
|
});
|
||||||
|
|
||||||
|
await fs!.enter();
|
||||||
|
el.requestFullscreen.mockClear();
|
||||||
|
await fs!.enter();
|
||||||
|
expect(el.requestFullscreen).not.toHaveBeenCalled();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exit() calls exitFullscreen and clears the flag', async () => {
|
||||||
|
const el = createFakeElement();
|
||||||
|
const document = createFakeDocument(el);
|
||||||
|
const scope = effectScope();
|
||||||
|
let fs: ReturnType<typeof useFullscreen>;
|
||||||
|
scope.run(() => {
|
||||||
|
fs = useFullscreen(undefined, { document: document as unknown as Document });
|
||||||
|
});
|
||||||
|
|
||||||
|
await fs!.enter();
|
||||||
|
await fs!.exit();
|
||||||
|
expect(document.exitFullscreen).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fs!.isFullscreen.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exit() is a no-op when not fullscreen', async () => {
|
||||||
|
const document = createFakeDocument();
|
||||||
|
const scope = effectScope();
|
||||||
|
let fs: ReturnType<typeof useFullscreen>;
|
||||||
|
scope.run(() => {
|
||||||
|
fs = useFullscreen(undefined, { document: document as unknown as Document });
|
||||||
|
});
|
||||||
|
|
||||||
|
await fs!.exit();
|
||||||
|
expect(document.exitFullscreen).not.toHaveBeenCalled();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggle() flips between enter and exit', async () => {
|
||||||
|
const el = createFakeElement();
|
||||||
|
const document = createFakeDocument(el);
|
||||||
|
const scope = effectScope();
|
||||||
|
let fs: ReturnType<typeof useFullscreen>;
|
||||||
|
scope.run(() => {
|
||||||
|
fs = useFullscreen(undefined, { document: document as unknown as Document });
|
||||||
|
});
|
||||||
|
|
||||||
|
await fs!.toggle();
|
||||||
|
expect(fs!.isFullscreen.value).toBeTruthy();
|
||||||
|
expect(el.requestFullscreen).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await fs!.toggle();
|
||||||
|
expect(fs!.isFullscreen.value).toBeFalsy();
|
||||||
|
expect(document.exitFullscreen).toHaveBeenCalledTimes(1);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when unsupported', async () => {
|
||||||
|
const document = {
|
||||||
|
documentElement: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
} as unknown as Document;
|
||||||
|
const scope = effectScope();
|
||||||
|
let fs: ReturnType<typeof useFullscreen>;
|
||||||
|
scope.run(() => {
|
||||||
|
fs = useFullscreen(undefined, { document });
|
||||||
|
});
|
||||||
|
|
||||||
|
await fs!.enter();
|
||||||
|
expect(fs!.isFullscreen.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('syncs isFullscreen to true on fullscreenchange when our element is the fullscreen element', async () => {
|
||||||
|
const document = createFakeDocument();
|
||||||
|
const scope = effectScope();
|
||||||
|
let fs: ReturnType<typeof useFullscreen>;
|
||||||
|
scope.run(() => {
|
||||||
|
fs = useFullscreen(undefined, { document: document as unknown as Document });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate the browser entering fullscreen for the document element.
|
||||||
|
document.fullScreen = true;
|
||||||
|
document.fullscreenElement = document.documentElement as unknown as Element;
|
||||||
|
document.dispatch('fullscreenchange');
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(fs!.isFullscreen.value).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('syncs isFullscreen to false on fullscreenchange when fullscreen ends', async () => {
|
||||||
|
const el = createFakeElement();
|
||||||
|
const document = createFakeDocument(el);
|
||||||
|
const scope = effectScope();
|
||||||
|
let fs: ReturnType<typeof useFullscreen>;
|
||||||
|
scope.run(() => {
|
||||||
|
fs = useFullscreen(undefined, { document: document as unknown as Document });
|
||||||
|
});
|
||||||
|
|
||||||
|
await fs!.enter();
|
||||||
|
expect(fs!.isFullscreen.value).toBeTruthy();
|
||||||
|
|
||||||
|
// Browser exits fullscreen (e.g. user pressed Escape).
|
||||||
|
document.fullScreen = false;
|
||||||
|
document.fullscreenElement = null;
|
||||||
|
document.dispatch('fullscreenchange');
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(fs!.isFullscreen.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves the target from a getter ref', async () => {
|
||||||
|
const target = createFakeElement();
|
||||||
|
const elRef = shallowRef<FakeEl | null>(null);
|
||||||
|
const document = createFakeDocument();
|
||||||
|
const scope = effectScope();
|
||||||
|
let fs: ReturnType<typeof useFullscreen>;
|
||||||
|
scope.run(() => {
|
||||||
|
fs = useFullscreen(() => elRef.value as unknown as HTMLElement, { document: document as unknown as Document });
|
||||||
|
});
|
||||||
|
|
||||||
|
elRef.value = target;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
await fs!.enter();
|
||||||
|
expect(target.requestFullscreen).toHaveBeenCalledTimes(1);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('autoExit exits fullscreen when the scope is disposed', async () => {
|
||||||
|
const el = createFakeElement();
|
||||||
|
const document = createFakeDocument(el);
|
||||||
|
const scope = effectScope();
|
||||||
|
let fs: ReturnType<typeof useFullscreen>;
|
||||||
|
scope.run(() => {
|
||||||
|
fs = useFullscreen(undefined, { document: document as unknown as Document, autoExit: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
await fs!.enter();
|
||||||
|
expect(fs!.isFullscreen.value).toBeTruthy();
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
// onScopeDispose triggers exit() (fire-and-forget); allow the microtask to flush.
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(document.exitFullscreen).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not autoExit by default', async () => {
|
||||||
|
const el = createFakeElement();
|
||||||
|
const document = createFakeDocument(el);
|
||||||
|
const scope = effectScope();
|
||||||
|
let fs: ReturnType<typeof useFullscreen>;
|
||||||
|
scope.run(() => {
|
||||||
|
fs = useFullscreen(undefined, { document: document as unknown as Document });
|
||||||
|
});
|
||||||
|
|
||||||
|
await fs!.enter();
|
||||||
|
scope.stop();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(document.exitFullscreen).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { computed, shallowRef } from 'vue';
|
||||||
|
import type { ComputedRef, ShallowRef } from 'vue';
|
||||||
|
import { isFunction } from '@robonen/stdlib';
|
||||||
|
import type { ConfigurableDocument } from '@/types';
|
||||||
|
import { defaultDocument } from '@/types';
|
||||||
|
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
||||||
|
import { unrefElement } from '@/composables/component/unrefElement';
|
||||||
|
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||||
|
import { useSupported } from '@/composables/browser/useSupported';
|
||||||
|
import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted';
|
||||||
|
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||||
|
|
||||||
|
export interface UseFullscreenOptions extends ConfigurableDocument {
|
||||||
|
/**
|
||||||
|
* Automatically exit fullscreen when the component is unmounted
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
autoExit?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseFullscreenReturn {
|
||||||
|
/**
|
||||||
|
* Whether the Fullscreen API is supported for the target element
|
||||||
|
*/
|
||||||
|
isSupported: ComputedRef<boolean>;
|
||||||
|
/**
|
||||||
|
* Whether the target element is currently in fullscreen mode
|
||||||
|
*/
|
||||||
|
isFullscreen: ShallowRef<boolean>;
|
||||||
|
/**
|
||||||
|
* Request fullscreen mode for the target element
|
||||||
|
*/
|
||||||
|
enter: () => Promise<void>;
|
||||||
|
/**
|
||||||
|
* Exit fullscreen mode
|
||||||
|
*/
|
||||||
|
exit: () => Promise<void>;
|
||||||
|
/**
|
||||||
|
* Toggle fullscreen mode for the target element
|
||||||
|
*/
|
||||||
|
toggle: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vendor-prefixed `fullscreenchange` event names across engines.
|
||||||
|
const eventHandlers = [
|
||||||
|
'fullscreenchange',
|
||||||
|
'webkitfullscreenchange',
|
||||||
|
'webkitendfullscreen',
|
||||||
|
'mozfullscreenchange',
|
||||||
|
'MSFullscreenChange',
|
||||||
|
] as unknown as Array<'fullscreenchange'>;
|
||||||
|
|
||||||
|
const requestMethods = [
|
||||||
|
'requestFullscreen',
|
||||||
|
'webkitRequestFullscreen',
|
||||||
|
'webkitEnterFullscreen',
|
||||||
|
'webkitEnterFullScreen',
|
||||||
|
'webkitRequestFullScreen',
|
||||||
|
'mozRequestFullScreen',
|
||||||
|
'msRequestFullscreen',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const exitMethods = [
|
||||||
|
'exitFullscreen',
|
||||||
|
'webkitExitFullscreen',
|
||||||
|
'webkitExitFullScreen',
|
||||||
|
'webkitCancelFullScreen',
|
||||||
|
'mozCancelFullScreen',
|
||||||
|
'msExitFullscreen',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const fullscreenFlags = [
|
||||||
|
'fullScreen',
|
||||||
|
'webkitIsFullScreen',
|
||||||
|
'webkitDisplayingFullscreen',
|
||||||
|
'mozFullScreen',
|
||||||
|
'msFullscreenElement',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const fullscreenElements = [
|
||||||
|
'fullscreenElement',
|
||||||
|
'webkitFullscreenElement',
|
||||||
|
'mozFullScreenElement',
|
||||||
|
'msFullscreenElement',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const listenerOptions = { capture: false, passive: true } as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useFullscreen
|
||||||
|
* @category Browser
|
||||||
|
* @description Reactive Fullscreen API for an element (or the document element).
|
||||||
|
* Handles vendor-prefixed fallbacks for request/exit/state detection and syncs
|
||||||
|
* `isFullscreen` from `fullscreenchange` events. SSR-safe.
|
||||||
|
*
|
||||||
|
* @param {MaybeComputedElementRef} [target] Element to display fullscreen (ref, getter, or component instance). Defaults to `document.documentElement`
|
||||||
|
* @param {UseFullscreenOptions} [options={}] Options (`document`, `autoExit`)
|
||||||
|
* @returns {UseFullscreenReturn} `{ isSupported, isFullscreen, enter, exit, toggle }`
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const el = useTemplateRef('el');
|
||||||
|
* const { isFullscreen, enter, exit, toggle } = useFullscreen(el);
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Fullscreen the whole page
|
||||||
|
* const { toggle } = useFullscreen();
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useFullscreen(
|
||||||
|
target?: MaybeComputedElementRef,
|
||||||
|
options: UseFullscreenOptions = {},
|
||||||
|
): UseFullscreenReturn {
|
||||||
|
const {
|
||||||
|
document = defaultDocument,
|
||||||
|
autoExit = false,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const targetRef = computed(() => unrefElement(target) ?? document?.documentElement);
|
||||||
|
const isFullscreen = shallowRef(false);
|
||||||
|
|
||||||
|
const has = (method: string): boolean =>
|
||||||
|
Boolean((document && method in document) || (targetRef.value && method in targetRef.value));
|
||||||
|
|
||||||
|
const requestMethod = computed<typeof requestMethods[number] | undefined>(
|
||||||
|
() => requestMethods.find(has),
|
||||||
|
);
|
||||||
|
|
||||||
|
const exitMethod = computed<typeof exitMethods[number] | undefined>(
|
||||||
|
() => exitMethods.find(has),
|
||||||
|
);
|
||||||
|
|
||||||
|
const fullscreenFlag = computed<typeof fullscreenFlags[number] | undefined>(
|
||||||
|
() => fullscreenFlags.find(has),
|
||||||
|
);
|
||||||
|
|
||||||
|
const fullscreenElementMethod = fullscreenElements.find(m => document && m in document);
|
||||||
|
|
||||||
|
const isSupported = useSupported(() =>
|
||||||
|
targetRef.value
|
||||||
|
&& document
|
||||||
|
&& requestMethod.value !== undefined
|
||||||
|
&& exitMethod.value !== undefined
|
||||||
|
&& fullscreenFlag.value !== undefined);
|
||||||
|
|
||||||
|
const isCurrentElementFullScreen = (): boolean => {
|
||||||
|
if (fullscreenElementMethod)
|
||||||
|
return (document as any)?.[fullscreenElementMethod] === targetRef.value;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isElementFullScreen = (): boolean => {
|
||||||
|
const flag = fullscreenFlag.value;
|
||||||
|
if (!flag)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const docFlag = document && (document as any)[flag];
|
||||||
|
if (docFlag !== null && docFlag !== undefined)
|
||||||
|
return Boolean(docFlag);
|
||||||
|
|
||||||
|
// Fallback for WebKit / iOS Safari, where the flag lives on the element itself.
|
||||||
|
const elFlag = (targetRef.value as any)?.[flag];
|
||||||
|
if (elFlag !== null && elFlag !== undefined)
|
||||||
|
return Boolean(elFlag);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function exit(): Promise<void> {
|
||||||
|
if (!isSupported.value || !isFullscreen.value)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const method = exitMethod.value;
|
||||||
|
if (method) {
|
||||||
|
if (typeof (document as any)?.[method] === 'function')
|
||||||
|
await (document as any)[method]();
|
||||||
|
else {
|
||||||
|
// Fallback for Safari iOS, where exit lives on the element.
|
||||||
|
const el = targetRef.value as any;
|
||||||
|
if (isFunction(el?.[method]))
|
||||||
|
await el[method]();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isFullscreen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enter(): Promise<void> {
|
||||||
|
if (!isSupported.value || isFullscreen.value)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (isElementFullScreen())
|
||||||
|
await exit();
|
||||||
|
|
||||||
|
const el = targetRef.value as any;
|
||||||
|
const method = requestMethod.value;
|
||||||
|
if (method && isFunction(el?.[method])) {
|
||||||
|
await el[method]();
|
||||||
|
isFullscreen.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggle(): Promise<void> {
|
||||||
|
await (isFullscreen.value ? exit() : enter());
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlerCallback = (): void => {
|
||||||
|
const elementFullScreen = isElementFullScreen();
|
||||||
|
// Only sync to `false`, or to `true` when *our* element is the fullscreen one,
|
||||||
|
// so multiple instances on the page don't clobber each other.
|
||||||
|
if (!elementFullScreen || (elementFullScreen && isCurrentElementFullScreen()))
|
||||||
|
isFullscreen.value = elementFullScreen;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEventListener(document, eventHandlers, handlerCallback, listenerOptions);
|
||||||
|
useEventListener(() => targetRef.value, eventHandlers, handlerCallback, listenerOptions);
|
||||||
|
|
||||||
|
tryOnMounted(handlerCallback, { sync: false });
|
||||||
|
|
||||||
|
if (autoExit)
|
||||||
|
tryOnScopeDispose(exit);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSupported,
|
||||||
|
isFullscreen,
|
||||||
|
enter,
|
||||||
|
exit,
|
||||||
|
toggle,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope, nextTick, shallowRef } from 'vue';
|
||||||
|
import { useGeolocation } from '.';
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
function stubGeolocation() {
|
||||||
|
let successCb: PositionCallback | null = null;
|
||||||
|
let errorCb: PositionErrorCallback | null = null;
|
||||||
|
let lastOptions: PositionOptions | undefined;
|
||||||
|
const watchPosition = vi.fn((success: PositionCallback, err?: PositionErrorCallback | null, opts?: PositionOptions) => {
|
||||||
|
successCb = success;
|
||||||
|
errorCb = err ?? null;
|
||||||
|
lastOptions = opts;
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
const clearWatch = vi.fn();
|
||||||
|
const getCurrentPosition = vi.fn((success: PositionCallback, err?: PositionErrorCallback | null, opts?: PositionOptions) => {
|
||||||
|
successCb = success;
|
||||||
|
errorCb = err ?? null;
|
||||||
|
lastOptions = opts;
|
||||||
|
});
|
||||||
|
const navigator = { geolocation: { watchPosition, clearWatch, getCurrentPosition } } as unknown as Navigator;
|
||||||
|
return {
|
||||||
|
navigator,
|
||||||
|
watchPosition,
|
||||||
|
clearWatch,
|
||||||
|
getCurrentPosition,
|
||||||
|
getOptions: () => lastOptions,
|
||||||
|
emit: (position: GeolocationPosition) => successCb?.(position),
|
||||||
|
emitError: (err: GeolocationPositionError) => errorCb?.(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePosition(latitude: number, longitude: number, timestamp = 123): GeolocationPosition {
|
||||||
|
return {
|
||||||
|
timestamp,
|
||||||
|
coords: {
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
accuracy: 5,
|
||||||
|
altitude: null,
|
||||||
|
altitudeAccuracy: null,
|
||||||
|
heading: null,
|
||||||
|
speed: null,
|
||||||
|
} as GeolocationCoordinates,
|
||||||
|
} as GeolocationPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeError(code = 1, message = 'denied'): GeolocationPositionError {
|
||||||
|
return { code, message, PERMISSION_DENIED: 1, POSITION_UNAVAILABLE: 2, TIMEOUT: 3 } as GeolocationPositionError;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(useGeolocation, () => {
|
||||||
|
it('starts watching immediately by default', () => {
|
||||||
|
const { watchPosition, navigator } = stubGeolocation();
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useGeolocation({ navigator }));
|
||||||
|
expect(watchPosition).toHaveBeenCalled();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not start watching when immediate is false', () => {
|
||||||
|
const { watchPosition, navigator } = stubGeolocation();
|
||||||
|
const scope = effectScope();
|
||||||
|
let geo: ReturnType<typeof useGeolocation>;
|
||||||
|
scope.run(() => {
|
||||||
|
geo = useGeolocation({ navigator, immediate: false });
|
||||||
|
});
|
||||||
|
expect(watchPosition).not.toHaveBeenCalled();
|
||||||
|
expect(geo!.isActive.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates coords on position change', () => {
|
||||||
|
const { emit, navigator } = stubGeolocation();
|
||||||
|
const scope = effectScope();
|
||||||
|
let geo: ReturnType<typeof useGeolocation>;
|
||||||
|
scope.run(() => {
|
||||||
|
geo = useGeolocation({ navigator });
|
||||||
|
});
|
||||||
|
|
||||||
|
emit(makePosition(1, 2));
|
||||||
|
|
||||||
|
expect(geo!.coords.value.latitude).toBe(1);
|
||||||
|
expect(geo!.coords.value.longitude).toBe(2);
|
||||||
|
expect(geo!.locatedAt.value).toBe(123);
|
||||||
|
expect(geo!.ready.value).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks active state across resume/pause', () => {
|
||||||
|
const { navigator } = stubGeolocation();
|
||||||
|
const scope = effectScope();
|
||||||
|
let geo: ReturnType<typeof useGeolocation>;
|
||||||
|
scope.run(() => {
|
||||||
|
geo = useGeolocation({ navigator });
|
||||||
|
});
|
||||||
|
expect(geo!.isActive.value).toBeTruthy();
|
||||||
|
geo!.pause();
|
||||||
|
expect(geo!.isActive.value).toBeFalsy();
|
||||||
|
geo!.resume();
|
||||||
|
expect(geo!.isActive.value).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the watch on pause', () => {
|
||||||
|
const { clearWatch, navigator } = stubGeolocation();
|
||||||
|
const scope = effectScope();
|
||||||
|
let geo: ReturnType<typeof useGeolocation>;
|
||||||
|
scope.run(() => {
|
||||||
|
geo = useGeolocation({ navigator });
|
||||||
|
});
|
||||||
|
geo!.pause();
|
||||||
|
expect(clearWatch).toHaveBeenCalledWith(1);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not re-watch when already active', () => {
|
||||||
|
const { watchPosition, navigator } = stubGeolocation();
|
||||||
|
const scope = effectScope();
|
||||||
|
let geo: ReturnType<typeof useGeolocation>;
|
||||||
|
scope.run(() => {
|
||||||
|
geo = useGeolocation({ navigator });
|
||||||
|
});
|
||||||
|
geo!.resume();
|
||||||
|
geo!.resume();
|
||||||
|
expect(watchPosition).toHaveBeenCalledTimes(1);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records errors and invokes onError', () => {
|
||||||
|
const { emitError, navigator } = stubGeolocation();
|
||||||
|
const onError = vi.fn();
|
||||||
|
const scope = effectScope();
|
||||||
|
let geo: ReturnType<typeof useGeolocation>;
|
||||||
|
scope.run(() => {
|
||||||
|
geo = useGeolocation({ navigator, onError });
|
||||||
|
});
|
||||||
|
|
||||||
|
const err = makeError();
|
||||||
|
emitError(err);
|
||||||
|
|
||||||
|
expect(geo!.error.value).toBe(err);
|
||||||
|
expect(onError).toHaveBeenCalledWith(err);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the error after a successful fix', () => {
|
||||||
|
const { emit, emitError, navigator } = stubGeolocation();
|
||||||
|
const scope = effectScope();
|
||||||
|
let geo: ReturnType<typeof useGeolocation>;
|
||||||
|
scope.run(() => {
|
||||||
|
geo = useGeolocation({ navigator });
|
||||||
|
});
|
||||||
|
|
||||||
|
emitError(makeError());
|
||||||
|
expect(geo!.error.value).not.toBeNull();
|
||||||
|
emit(makePosition(1, 2));
|
||||||
|
expect(geo!.error.value).toBeNull();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes position options to watchPosition', () => {
|
||||||
|
const { getOptions, navigator } = stubGeolocation();
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useGeolocation({ navigator, enableHighAccuracy: false, maximumAge: 1000, timeout: 5000 }));
|
||||||
|
expect(getOptions()).toEqual({ enableHighAccuracy: false, maximumAge: 1000, timeout: 5000 });
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restarts the watcher when reactive options change while active', async () => {
|
||||||
|
const { watchPosition, clearWatch, getOptions, navigator } = stubGeolocation();
|
||||||
|
const highAccuracy = shallowRef(false);
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useGeolocation({ navigator, enableHighAccuracy: highAccuracy }));
|
||||||
|
|
||||||
|
expect(watchPosition).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getOptions()?.enableHighAccuracy).toBeFalsy();
|
||||||
|
|
||||||
|
highAccuracy.value = true;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(clearWatch).toHaveBeenCalledWith(1);
|
||||||
|
expect(watchPosition).toHaveBeenCalledTimes(2);
|
||||||
|
expect(getOptions()?.enableHighAccuracy).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not restart on option change when paused', async () => {
|
||||||
|
const { watchPosition, navigator } = stubGeolocation();
|
||||||
|
const timeout = shallowRef(1000);
|
||||||
|
const scope = effectScope();
|
||||||
|
let geo: ReturnType<typeof useGeolocation>;
|
||||||
|
scope.run(() => {
|
||||||
|
geo = useGeolocation({ navigator, timeout, immediate: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
timeout.value = 2000;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(watchPosition).not.toHaveBeenCalled();
|
||||||
|
expect(geo!.isActive.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getCurrentPosition resolves and updates state without a watch', async () => {
|
||||||
|
const { watchPosition, getCurrentPosition, emit, navigator } = stubGeolocation();
|
||||||
|
const scope = effectScope();
|
||||||
|
let geo: ReturnType<typeof useGeolocation>;
|
||||||
|
scope.run(() => {
|
||||||
|
geo = useGeolocation({ navigator, immediate: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
const promise = geo!.getCurrentPosition();
|
||||||
|
emit(makePosition(10, 20, 999));
|
||||||
|
const position = await promise;
|
||||||
|
|
||||||
|
expect(getCurrentPosition).toHaveBeenCalled();
|
||||||
|
expect(watchPosition).not.toHaveBeenCalled();
|
||||||
|
expect(position.coords.latitude).toBe(10);
|
||||||
|
expect(geo!.coords.value.latitude).toBe(10);
|
||||||
|
expect(geo!.locatedAt.value).toBe(999);
|
||||||
|
expect(geo!.ready.value).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getCurrentPosition rejects on error', async () => {
|
||||||
|
const { emitError, navigator } = stubGeolocation();
|
||||||
|
const scope = effectScope();
|
||||||
|
let geo: ReturnType<typeof useGeolocation>;
|
||||||
|
scope.run(() => {
|
||||||
|
geo = useGeolocation({ navigator, immediate: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
const promise = geo!.getCurrentPosition();
|
||||||
|
const err = makeError();
|
||||||
|
emitError(err);
|
||||||
|
|
||||||
|
await expect(promise).rejects.toBe(err);
|
||||||
|
expect(geo!.error.value).toBe(err);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports unsupported when geolocation is missing', () => {
|
||||||
|
const navigator = {} as Navigator;
|
||||||
|
const scope = effectScope();
|
||||||
|
let geo: ReturnType<typeof useGeolocation>;
|
||||||
|
scope.run(() => {
|
||||||
|
geo = useGeolocation({ navigator });
|
||||||
|
});
|
||||||
|
expect(geo!.isSupported.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getCurrentPosition rejects when unsupported', async () => {
|
||||||
|
const navigator = {} as Navigator;
|
||||||
|
const scope = effectScope();
|
||||||
|
let geo: ReturnType<typeof useGeolocation>;
|
||||||
|
scope.run(() => {
|
||||||
|
geo = useGeolocation({ navigator, immediate: false });
|
||||||
|
});
|
||||||
|
await expect(geo!.getCurrentPosition()).rejects.toThrow('not supported');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import { shallowReadonly, shallowRef, toValue, watch } from 'vue';
|
||||||
|
import type { MaybeRefOrGetter, Ref } from 'vue';
|
||||||
|
import { noop } from '@robonen/stdlib';
|
||||||
|
import { defaultNavigator } from '@/types';
|
||||||
|
import type { ConfigurableNavigator } from '@/types';
|
||||||
|
import { useSupported } from '@/composables/browser/useSupported';
|
||||||
|
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||||
|
|
||||||
|
export interface UseGeolocationOptions extends ConfigurableNavigator {
|
||||||
|
/**
|
||||||
|
* A boolean that indicates the application would like to receive the best
|
||||||
|
* possible results. Reactive — changing it while watching restarts the watcher.
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
enableHighAccuracy?: MaybeRefOrGetter<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The maximum age in milliseconds of a possible cached position that is
|
||||||
|
* acceptable to return. Reactive — changing it while watching restarts the watcher.
|
||||||
|
*
|
||||||
|
* @default 30000
|
||||||
|
*/
|
||||||
|
maximumAge?: MaybeRefOrGetter<number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The maximum length of time in milliseconds the device is allowed to take in
|
||||||
|
* order to return a position. Reactive — changing it while watching restarts the watcher.
|
||||||
|
*
|
||||||
|
* @default 27000
|
||||||
|
*/
|
||||||
|
timeout?: MaybeRefOrGetter<number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start watching the position immediately
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
immediate?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called whenever the Geolocation API reports an error. Receives the
|
||||||
|
* `GeolocationPositionError`. Useful for reacting to permission denials or
|
||||||
|
* timeouts without setting up a watcher on `error`.
|
||||||
|
*
|
||||||
|
* @default () => {}
|
||||||
|
*/
|
||||||
|
onError?: (error: GeolocationPositionError) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseGeolocationReturn {
|
||||||
|
/**
|
||||||
|
* Whether the Geolocation API is supported in the current environment.
|
||||||
|
*/
|
||||||
|
isSupported: Readonly<Ref<boolean>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The most recent set of coordinates.
|
||||||
|
*/
|
||||||
|
coords: Readonly<Ref<Omit<GeolocationPosition['coords'], 'toJSON'>>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The timestamp of the most recent position, or `null` before the first fix.
|
||||||
|
*/
|
||||||
|
locatedAt: Readonly<Ref<number | null>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The most recent error, or `null` if none.
|
||||||
|
*/
|
||||||
|
error: Readonly<Ref<GeolocationPositionError | null>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether at least one position fix has been received.
|
||||||
|
*/
|
||||||
|
ready: Readonly<Ref<boolean>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the position is currently being watched.
|
||||||
|
*/
|
||||||
|
isActive: Readonly<Ref<boolean>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start watching the position.
|
||||||
|
*/
|
||||||
|
resume: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop watching the position.
|
||||||
|
*/
|
||||||
|
pause: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request the current position once, without starting a continuous watch.
|
||||||
|
* Resolves with the position (and updates `coords`/`locatedAt`) or rejects
|
||||||
|
* with a `GeolocationPositionError`.
|
||||||
|
*/
|
||||||
|
getCurrentPosition: () => Promise<GeolocationPosition>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_COORDS: Omit<GeolocationPosition['coords'], 'toJSON'> = {
|
||||||
|
accuracy: 0,
|
||||||
|
latitude: Number.POSITIVE_INFINITY,
|
||||||
|
longitude: Number.POSITIVE_INFINITY,
|
||||||
|
altitude: null,
|
||||||
|
altitudeAccuracy: null,
|
||||||
|
heading: null,
|
||||||
|
speed: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useGeolocation
|
||||||
|
* @category Browser
|
||||||
|
* @description Reactive Geolocation API. Watches the device position, exposing
|
||||||
|
* reactive coordinates, error, and readiness state, plus pause/resume controls
|
||||||
|
* and a one-shot `getCurrentPosition`.
|
||||||
|
*
|
||||||
|
* @param {UseGeolocationOptions} [options={}] Options
|
||||||
|
* @returns {UseGeolocationReturn} Reactive position, error, readiness, and watch controls
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { coords, locatedAt, error, ready } = useGeolocation();
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // One-shot fetch without a continuous watch
|
||||||
|
* const { getCurrentPosition } = useGeolocation({ immediate: false });
|
||||||
|
* const position = await getCurrentPosition();
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useGeolocation(options: UseGeolocationOptions = {}): UseGeolocationReturn {
|
||||||
|
const {
|
||||||
|
enableHighAccuracy = true,
|
||||||
|
maximumAge = 30000,
|
||||||
|
timeout = 27000,
|
||||||
|
navigator = defaultNavigator,
|
||||||
|
immediate = true,
|
||||||
|
onError = noop,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const isSupported = useSupported(() => navigator && 'geolocation' in navigator);
|
||||||
|
|
||||||
|
const locatedAt = shallowRef<number | null>(null);
|
||||||
|
const error = shallowRef<GeolocationPositionError | null>(null);
|
||||||
|
const coords = shallowRef<Omit<GeolocationPosition['coords'], 'toJSON'>>(DEFAULT_COORDS);
|
||||||
|
const ready = shallowRef(false);
|
||||||
|
const isActive = shallowRef(false);
|
||||||
|
|
||||||
|
function resolveOptions(): PositionOptions {
|
||||||
|
return {
|
||||||
|
enableHighAccuracy: toValue(enableHighAccuracy),
|
||||||
|
maximumAge: toValue(maximumAge),
|
||||||
|
timeout: toValue(timeout),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePosition(position: GeolocationPosition): void {
|
||||||
|
locatedAt.value = position.timestamp;
|
||||||
|
coords.value = position.coords;
|
||||||
|
error.value = null;
|
||||||
|
ready.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleError(err: GeolocationPositionError): void {
|
||||||
|
error.value = err;
|
||||||
|
onError(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
let watcher: number | null = null;
|
||||||
|
|
||||||
|
function resume(): void {
|
||||||
|
if (!isSupported.value || !navigator || watcher !== null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
watcher = navigator.geolocation.watchPosition(updatePosition, handleError, resolveOptions());
|
||||||
|
isActive.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pause(): void {
|
||||||
|
if (watcher === null || !navigator)
|
||||||
|
return;
|
||||||
|
|
||||||
|
navigator.geolocation.clearWatch(watcher);
|
||||||
|
watcher = null;
|
||||||
|
isActive.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentPosition(): Promise<GeolocationPosition> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!isSupported.value || !navigator) {
|
||||||
|
reject(new Error('Geolocation is not supported'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(position) => {
|
||||||
|
updatePosition(position);
|
||||||
|
resolve(position);
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
handleError(err);
|
||||||
|
reject(err);
|
||||||
|
},
|
||||||
|
resolveOptions(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart the watcher when reactive position options change while active.
|
||||||
|
watch(
|
||||||
|
() => resolveOptions(),
|
||||||
|
() => {
|
||||||
|
if (isActive.value) {
|
||||||
|
pause();
|
||||||
|
resume();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (immediate)
|
||||||
|
resume();
|
||||||
|
|
||||||
|
tryOnScopeDispose(pause);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSupported,
|
||||||
|
coords,
|
||||||
|
locatedAt: shallowReadonly(locatedAt),
|
||||||
|
error: shallowReadonly(error),
|
||||||
|
ready: shallowReadonly(ready),
|
||||||
|
isActive: shallowReadonly(isActive),
|
||||||
|
resume,
|
||||||
|
pause,
|
||||||
|
getCurrentPosition,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope } from 'vue';
|
||||||
|
import { useIdle } from '.';
|
||||||
|
import { bypassFilter } from '@/utils/filters';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal EventTarget-like stub so we can drive listeners deterministically
|
||||||
|
* without relying on jsdom's real window/document timing.
|
||||||
|
*/
|
||||||
|
function createTarget() {
|
||||||
|
const listeners = new Map<string, Set<EventListener>>();
|
||||||
|
return {
|
||||||
|
addEventListener(type: string, listener: EventListener) {
|
||||||
|
if (!listeners.has(type))
|
||||||
|
listeners.set(type, new Set());
|
||||||
|
listeners.get(type)!.add(listener);
|
||||||
|
},
|
||||||
|
removeEventListener(type: string, listener: EventListener) {
|
||||||
|
listeners.get(type)?.delete(listener);
|
||||||
|
},
|
||||||
|
dispatch(type: string, event: Event = { type } as Event) {
|
||||||
|
listeners.get(type)?.forEach(fn => fn(event));
|
||||||
|
},
|
||||||
|
count(type: string) {
|
||||||
|
return listeners.get(type)?.size ?? 0;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindow(doc: ReturnType<typeof createTarget> & { hidden?: boolean }) {
|
||||||
|
const win = createTarget() as ReturnType<typeof createTarget> & { document: typeof doc };
|
||||||
|
win.document = doc;
|
||||||
|
return win;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(useIdle, () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(1000);
|
||||||
|
});
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
it('starts not idle and exposes lastActive', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
const doc = createTarget();
|
||||||
|
const window = createWindow(doc) as any;
|
||||||
|
const { idle, lastActive, isPending } = useIdle(1000, { window });
|
||||||
|
|
||||||
|
expect(idle.value).toBeFalsy();
|
||||||
|
expect(isPending.value).toBeTruthy();
|
||||||
|
expect(lastActive.value).toBe(1000);
|
||||||
|
});
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('becomes idle after the timeout elapses with no activity', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
const doc = createTarget();
|
||||||
|
const window = createWindow(doc) as any;
|
||||||
|
const { idle } = useIdle(1000, { window });
|
||||||
|
|
||||||
|
expect(idle.value).toBeFalsy();
|
||||||
|
vi.advanceTimersByTime(999);
|
||||||
|
expect(idle.value).toBeFalsy();
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(idle.value).toBeTruthy();
|
||||||
|
});
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets idle state and lastActive on user activity', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
const doc = createTarget();
|
||||||
|
const window = createWindow(doc) as any;
|
||||||
|
const { idle, lastActive } = useIdle(1000, { window, eventFilter: bypassFilter });
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(idle.value).toBeTruthy();
|
||||||
|
|
||||||
|
vi.setSystemTime(2500);
|
||||||
|
window.dispatch('mousemove');
|
||||||
|
|
||||||
|
expect(idle.value).toBeFalsy();
|
||||||
|
expect(lastActive.value).toBe(2500);
|
||||||
|
});
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restarts the timeout after activity', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
const doc = createTarget();
|
||||||
|
const window = createWindow(doc) as any;
|
||||||
|
const { idle } = useIdle(1000, { window, eventFilter: bypassFilter });
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
window.dispatch('keydown');
|
||||||
|
// 500ms more would have been idle without the reset
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
expect(idle.value).toBeFalsy();
|
||||||
|
// full timeout from the reset
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
expect(idle.value).toBeTruthy();
|
||||||
|
});
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors a custom events list', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
const doc = createTarget();
|
||||||
|
const window = createWindow(doc) as any;
|
||||||
|
useIdle(1000, { window, events: ['keydown'] });
|
||||||
|
|
||||||
|
expect(window.count('keydown')).toBe(1);
|
||||||
|
expect(window.count('mousemove')).toBe(0);
|
||||||
|
});
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listens for visibilitychange by default and resets when visible', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
const doc = createTarget() as any;
|
||||||
|
doc.hidden = false;
|
||||||
|
const window = createWindow(doc) as any;
|
||||||
|
const { idle } = useIdle(1000, { window, eventFilter: bypassFilter });
|
||||||
|
|
||||||
|
expect(doc.count('visibilitychange')).toBe(1);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(idle.value).toBeTruthy();
|
||||||
|
|
||||||
|
doc.dispatch('visibilitychange');
|
||||||
|
expect(idle.value).toBeFalsy();
|
||||||
|
});
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores visibilitychange when the document is hidden', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
const doc = createTarget() as any;
|
||||||
|
doc.hidden = true;
|
||||||
|
const window = createWindow(doc) as any;
|
||||||
|
const { idle } = useIdle(1000, { window, eventFilter: bypassFilter });
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(idle.value).toBeTruthy();
|
||||||
|
|
||||||
|
doc.dispatch('visibilitychange');
|
||||||
|
expect(idle.value).toBeTruthy();
|
||||||
|
});
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not register visibilitychange when disabled', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
const doc = createTarget();
|
||||||
|
const window = createWindow(doc) as any;
|
||||||
|
useIdle(1000, { window, listenForVisibilityChange: false });
|
||||||
|
|
||||||
|
expect(doc.count('visibilitychange')).toBe(0);
|
||||||
|
});
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects initialState: true (starts idle, no timer)', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
const doc = createTarget();
|
||||||
|
const window = createWindow(doc) as any;
|
||||||
|
const { idle } = useIdle(1000, { window, initialState: true });
|
||||||
|
|
||||||
|
expect(idle.value).toBeTruthy();
|
||||||
|
vi.advanceTimersByTime(5000);
|
||||||
|
// no reset was scheduled, so it stays in the initial state
|
||||||
|
expect(idle.value).toBeTruthy();
|
||||||
|
});
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reset() manually clears idle and restarts the timer', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
const doc = createTarget();
|
||||||
|
const window = createWindow(doc) as any;
|
||||||
|
const { idle, reset } = useIdle(1000, { window });
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(idle.value).toBeTruthy();
|
||||||
|
|
||||||
|
reset();
|
||||||
|
expect(idle.value).toBeFalsy();
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(idle.value).toBeTruthy();
|
||||||
|
});
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stop() halts tracking and start() resumes it', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
const doc = createTarget();
|
||||||
|
const window = createWindow(doc) as any;
|
||||||
|
const { idle, isPending, start, stop } = useIdle(1000, { window, eventFilter: bypassFilter });
|
||||||
|
|
||||||
|
stop();
|
||||||
|
expect(isPending.value).toBeFalsy();
|
||||||
|
expect(idle.value).toBeFalsy();
|
||||||
|
|
||||||
|
// events are ignored while stopped
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(idle.value).toBeFalsy();
|
||||||
|
window.dispatch('mousemove');
|
||||||
|
expect(idle.value).toBeFalsy();
|
||||||
|
|
||||||
|
start();
|
||||||
|
expect(isPending.value).toBeTruthy();
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(idle.value).toBeTruthy();
|
||||||
|
});
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes listeners when the scope is disposed', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let win: any;
|
||||||
|
scope.run(() => {
|
||||||
|
const doc = createTarget();
|
||||||
|
win = createWindow(doc) as any;
|
||||||
|
useIdle(1000, { window: win });
|
||||||
|
expect(win.count('mousemove')).toBe(1);
|
||||||
|
});
|
||||||
|
scope.stop();
|
||||||
|
expect(win.count('mousemove')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is SSR-safe when no window is available', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
// simulate an environment with no window (the guard sees a falsy target)
|
||||||
|
const { idle, isPending, lastActive } = useIdle(1000, { window: null as any });
|
||||||
|
|
||||||
|
// never started: stays in initial state and never schedules a timer
|
||||||
|
expect(idle.value).toBeFalsy();
|
||||||
|
expect(isPending.value).toBeFalsy();
|
||||||
|
expect(lastActive.value).toBe(1000);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(5000);
|
||||||
|
expect(idle.value).toBeFalsy();
|
||||||
|
});
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { shallowReadonly, shallowRef } from 'vue';
|
||||||
|
import type { ShallowRef } from 'vue';
|
||||||
|
import { timestamp } from '@robonen/stdlib';
|
||||||
|
import { defaultWindow } from '@/types';
|
||||||
|
import type { ConfigurableWindow } from '@/types';
|
||||||
|
import { createFilterWrapper, throttleFilter } from '@/utils/filters';
|
||||||
|
import type { ConfigurableEventFilter } from '@/utils/filters';
|
||||||
|
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||||
|
import type { WindowEventName } from '@/composables/browser/useEventListener';
|
||||||
|
|
||||||
|
const DEFAULT_EVENTS: WindowEventName[] = ['mousemove', 'mousedown', 'resize', 'keydown', 'touchstart', 'wheel'];
|
||||||
|
const ONE_MINUTE = 60_000;
|
||||||
|
|
||||||
|
export interface UseIdleOptions extends ConfigurableWindow, ConfigurableEventFilter {
|
||||||
|
/**
|
||||||
|
* Event names to listen to for detecting user activity
|
||||||
|
*
|
||||||
|
* @default ['mousemove', 'mousedown', 'resize', 'keydown', 'touchstart', 'wheel']
|
||||||
|
*/
|
||||||
|
events?: WindowEventName[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the idle timer when the document becomes visible again
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
listenForVisibilityChange?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initial value of the `idle` ref
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
initialState?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseIdleReturn {
|
||||||
|
/**
|
||||||
|
* Whether the user is currently idle
|
||||||
|
*/
|
||||||
|
idle: ShallowRef<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timestamp (ms) of the last detected user activity
|
||||||
|
*/
|
||||||
|
lastActive: ShallowRef<number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the idle tracker is currently running
|
||||||
|
*/
|
||||||
|
isPending: Readonly<ShallowRef<boolean>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually mark the user as active and restart the idle timer
|
||||||
|
*/
|
||||||
|
reset: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Begin (or resume) tracking. Restarts the idle timer unless `initialState` is `true`
|
||||||
|
*/
|
||||||
|
start: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop tracking. Resets `idle` to `initialState` and clears the pending timer
|
||||||
|
*/
|
||||||
|
stop: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useIdle
|
||||||
|
* @category Browser
|
||||||
|
* @description Track whether the user has been inactive for a given duration.
|
||||||
|
*
|
||||||
|
* @param {number} [timeout=60000] Idle threshold in milliseconds
|
||||||
|
* @param {UseIdleOptions} [options={}] Options
|
||||||
|
* @returns {UseIdleReturn} `{ idle, lastActive, isPending, reset, start, stop }`
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { idle, lastActive, reset } = useIdle(5 * 60_000); // 5 minutes
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { idle } = useIdle(10_000, { events: ['keydown'] });
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useIdle(
|
||||||
|
timeout: number = ONE_MINUTE,
|
||||||
|
options: UseIdleOptions = {},
|
||||||
|
): UseIdleReturn {
|
||||||
|
const {
|
||||||
|
initialState = false,
|
||||||
|
listenForVisibilityChange = true,
|
||||||
|
events = DEFAULT_EVENTS,
|
||||||
|
window = defaultWindow,
|
||||||
|
eventFilter = throttleFilter(50),
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const idle = shallowRef(initialState);
|
||||||
|
const lastActive = shallowRef(timestamp());
|
||||||
|
const isPending = shallowRef(false);
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
const reset = (): void => {
|
||||||
|
idle.value = false;
|
||||||
|
if (timer !== undefined)
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
idle.value = true;
|
||||||
|
}, timeout);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEvent = createFilterWrapper(
|
||||||
|
eventFilter,
|
||||||
|
() => {
|
||||||
|
lastActive.value = timestamp();
|
||||||
|
reset();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const start = (): void => {
|
||||||
|
if (isPending.value)
|
||||||
|
return;
|
||||||
|
isPending.value = true;
|
||||||
|
if (!initialState)
|
||||||
|
reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = (): void => {
|
||||||
|
idle.value = initialState;
|
||||||
|
if (timer !== undefined) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = undefined;
|
||||||
|
}
|
||||||
|
isPending.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (window) {
|
||||||
|
const document = window.document;
|
||||||
|
const listenerOptions = { passive: true };
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
useEventListener(window, event, () => {
|
||||||
|
if (!isPending.value)
|
||||||
|
return;
|
||||||
|
onEvent();
|
||||||
|
}, listenerOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listenForVisibilityChange) {
|
||||||
|
useEventListener(document, 'visibilitychange', () => {
|
||||||
|
if (document.hidden || !isPending.value)
|
||||||
|
return;
|
||||||
|
onEvent();
|
||||||
|
}, listenerOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
idle,
|
||||||
|
lastActive,
|
||||||
|
isPending: shallowReadonly(isPending),
|
||||||
|
reset,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope, nextTick, ref } from 'vue';
|
||||||
|
import { useIntersectionObserver } from '.';
|
||||||
|
|
||||||
|
interface StubInstance {
|
||||||
|
cb: IntersectionObserverCallback;
|
||||||
|
options?: IntersectionObserverInit;
|
||||||
|
observe: ReturnType<typeof vi.fn>;
|
||||||
|
disconnect: ReturnType<typeof vi.fn>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let instances: StubInstance[] = [];
|
||||||
|
|
||||||
|
class StubIntersectionObserver {
|
||||||
|
observe = vi.fn();
|
||||||
|
disconnect = vi.fn();
|
||||||
|
unobserve = vi.fn();
|
||||||
|
takeRecords = vi.fn();
|
||||||
|
cb: IntersectionObserverCallback;
|
||||||
|
options?: IntersectionObserverInit;
|
||||||
|
constructor(cb: IntersectionObserverCallback, options?: IntersectionObserverInit) {
|
||||||
|
this.cb = cb;
|
||||||
|
this.options = options;
|
||||||
|
instances.push(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(useIntersectionObserver, () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
instances = [];
|
||||||
|
vi.stubGlobal('IntersectionObserver', StubIntersectionObserver);
|
||||||
|
});
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('observes the target immediately', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useIntersectionObserver(ref(el), vi.fn()));
|
||||||
|
|
||||||
|
expect(instances).toHaveLength(1);
|
||||||
|
expect(instances[0]!.observe).toHaveBeenCalledWith(el);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not observe when immediate is false', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useIntersectionObserver(ref(el), vi.fn(), { immediate: false }));
|
||||||
|
|
||||||
|
expect(instances).toHaveLength(0);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pause disconnects and resume re-observes', async () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
let controls: ReturnType<typeof useIntersectionObserver>;
|
||||||
|
scope.run(() => {
|
||||||
|
controls = useIntersectionObserver(ref(el), vi.fn());
|
||||||
|
});
|
||||||
|
|
||||||
|
controls!.pause();
|
||||||
|
expect(instances[0]!.disconnect).toHaveBeenCalled();
|
||||||
|
expect(controls!.isActive.value).toBeFalsy();
|
||||||
|
|
||||||
|
controls!.resume();
|
||||||
|
await nextTick();
|
||||||
|
expect(controls!.isActive.value).toBeTruthy();
|
||||||
|
expect(instances).toHaveLength(2);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stop disconnects and marks inactive', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
let controls: ReturnType<typeof useIntersectionObserver>;
|
||||||
|
scope.run(() => {
|
||||||
|
controls = useIntersectionObserver(ref(el), vi.fn());
|
||||||
|
});
|
||||||
|
|
||||||
|
controls!.stop();
|
||||||
|
expect(instances[0]!.disconnect).toHaveBeenCalled();
|
||||||
|
expect(controls!.isActive.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invokes the callback with entries', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const callback = vi.fn();
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useIntersectionObserver(ref(el), callback));
|
||||||
|
|
||||||
|
const entry = { isIntersecting: true, time: 1 } as IntersectionObserverEntry;
|
||||||
|
instances[0]!.cb([entry], instances[0] as unknown as IntersectionObserver);
|
||||||
|
expect(callback).toHaveBeenCalled();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('observes an array of targets', () => {
|
||||||
|
const a = document.createElement('div');
|
||||||
|
const b = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useIntersectionObserver([ref(a), ref(b)], vi.fn()));
|
||||||
|
|
||||||
|
expect(instances).toHaveLength(1);
|
||||||
|
expect(instances[0]!.observe).toHaveBeenCalledWith(a);
|
||||||
|
expect(instances[0]!.observe).toHaveBeenCalledWith(b);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks a reactive target ref of an array', async () => {
|
||||||
|
const a = document.createElement('div');
|
||||||
|
const b = document.createElement('div');
|
||||||
|
const list = ref([a]);
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useIntersectionObserver(list, vi.fn()));
|
||||||
|
|
||||||
|
expect(instances).toHaveLength(1);
|
||||||
|
expect(instances[0]!.observe).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
list.value = [a, b];
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
// recreated with both elements
|
||||||
|
expect(instances).toHaveLength(2);
|
||||||
|
expect(instances[1]!.observe).toHaveBeenCalledWith(a);
|
||||||
|
expect(instances[1]!.observe).toHaveBeenCalledWith(b);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks a getter target', async () => {
|
||||||
|
const a = document.createElement('div');
|
||||||
|
const enabled = ref(false);
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useIntersectionObserver(() => (enabled.value ? a : null), vi.fn()));
|
||||||
|
|
||||||
|
// null target -> no observer
|
||||||
|
expect(instances).toHaveLength(0);
|
||||||
|
|
||||||
|
enabled.value = true;
|
||||||
|
await nextTick();
|
||||||
|
expect(instances).toHaveLength(1);
|
||||||
|
expect(instances[0]!.observe).toHaveBeenCalledWith(a);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes rootMargin and threshold to the observer', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useIntersectionObserver(ref(el), vi.fn(), { rootMargin: '10px', threshold: [0, 0.5, 1] }));
|
||||||
|
|
||||||
|
expect(instances[0]!.options?.rootMargin).toBe('10px');
|
||||||
|
expect(instances[0]!.options?.threshold).toEqual([0, 0.5, 1]);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reacts to a reactive rootMargin', async () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const rootMargin = ref('0px');
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useIntersectionObserver(ref(el), vi.fn(), { rootMargin }));
|
||||||
|
|
||||||
|
expect(instances[0]!.options?.rootMargin).toBe('0px');
|
||||||
|
|
||||||
|
rootMargin.value = '20px';
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(instances).toHaveLength(2);
|
||||||
|
expect(instances[1]!.options?.rootMargin).toBe('20px');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reacts to a reactive threshold', async () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const threshold = ref<number | number[]>(0);
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useIntersectionObserver(ref(el), vi.fn(), { threshold }));
|
||||||
|
|
||||||
|
expect(instances[0]!.options?.threshold).toBe(0);
|
||||||
|
|
||||||
|
threshold.value = 0.75;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(instances).toHaveLength(2);
|
||||||
|
expect(instances[1]!.options?.threshold).toBe(0.75);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports unsupported and never constructs an observer', () => {
|
||||||
|
// jsdom has no native IntersectionObserver; remove the stub so the
|
||||||
|
// feature detection `'IntersectionObserver' in window` reports false.
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
delete (globalThis as Record<string, unknown>).IntersectionObserver;
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
let controls: ReturnType<typeof useIntersectionObserver>;
|
||||||
|
scope.run(() => {
|
||||||
|
controls = useIntersectionObserver(ref(el), vi.fn());
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(controls!.isSupported.value).toBeFalsy();
|
||||||
|
// stop should be a safe no-op
|
||||||
|
expect(() => controls!.stop()).not.toThrow();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { computed, readonly, ref, toValue, watch } from 'vue';
|
||||||
|
import type { MaybeRefOrGetter, Ref } from 'vue';
|
||||||
|
import { noop, toArray } from '@robonen/stdlib';
|
||||||
|
import type { ConfigurableWindow } from '@/types';
|
||||||
|
import { defaultWindow } from '@/types';
|
||||||
|
import type { MaybeComputedElementRef, MaybeElement } from '@/composables/component/unrefElement';
|
||||||
|
import { unrefElement } from '@/composables/component/unrefElement';
|
||||||
|
import { useSupported } from '@/composables/browser/useSupported';
|
||||||
|
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||||
|
|
||||||
|
export interface UseIntersectionObserverOptions extends ConfigurableWindow {
|
||||||
|
/**
|
||||||
|
* The element or document used as the viewport for checking visibility
|
||||||
|
*/
|
||||||
|
root?: MaybeComputedElementRef | Document;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Margin around the root. Reactive — pass a ref or getter to update it.
|
||||||
|
*
|
||||||
|
* @default '0px'
|
||||||
|
*/
|
||||||
|
rootMargin?: MaybeRefOrGetter<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Threshold(s) at which to trigger the callback. Reactive — pass a ref or
|
||||||
|
* getter to update it.
|
||||||
|
*
|
||||||
|
* @default 0
|
||||||
|
*/
|
||||||
|
threshold?: MaybeRefOrGetter<number | number[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start observing immediately
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
immediate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseIntersectionObserverReturn {
|
||||||
|
isSupported: Readonly<Ref<boolean>>;
|
||||||
|
isActive: Readonly<Ref<boolean>>;
|
||||||
|
pause: () => void;
|
||||||
|
resume: () => void;
|
||||||
|
stop: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useIntersectionObserver
|
||||||
|
* @category Browser
|
||||||
|
* @description Detect when an element enters or leaves the viewport via
|
||||||
|
* `IntersectionObserver`. Accepts a single target, an array of targets, or a
|
||||||
|
* ref/getter resolving to either, plus reactive `rootMargin` and `threshold`.
|
||||||
|
*
|
||||||
|
* @param {MaybeComputedElementRef | MaybeComputedElementRef[] | MaybeRefOrGetter<MaybeElement[]>} target Element(s) to observe
|
||||||
|
* @param {IntersectionObserverCallback} callback Invoked with the observer entries
|
||||||
|
* @param {UseIntersectionObserverOptions} [options={}] Options
|
||||||
|
* @returns {UseIntersectionObserverReturn} Observer controls
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* useIntersectionObserver(el, ([{ isIntersecting }]) => {
|
||||||
|
* visible.value = isIntersecting;
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useIntersectionObserver(
|
||||||
|
target: MaybeComputedElementRef | MaybeComputedElementRef[] | MaybeRefOrGetter<MaybeElement[]>,
|
||||||
|
callback: IntersectionObserverCallback,
|
||||||
|
options: UseIntersectionObserverOptions = {},
|
||||||
|
): UseIntersectionObserverReturn {
|
||||||
|
const {
|
||||||
|
root,
|
||||||
|
rootMargin = '0px',
|
||||||
|
threshold = 0,
|
||||||
|
window = defaultWindow,
|
||||||
|
immediate = true,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const isSupported = useSupported(() => window && 'IntersectionObserver' in window);
|
||||||
|
|
||||||
|
const targets = computed(() => {
|
||||||
|
const value = toValue(target) as MaybeElement | MaybeElement[];
|
||||||
|
return toArray(value as MaybeElement)
|
||||||
|
.map(el => unrefElement(el))
|
||||||
|
.filter((el): el is Element => Boolean(el));
|
||||||
|
});
|
||||||
|
|
||||||
|
const isActive = ref(immediate);
|
||||||
|
|
||||||
|
let cleanup = noop;
|
||||||
|
|
||||||
|
const stopWatch = isSupported.value
|
||||||
|
? watch(
|
||||||
|
() => [
|
||||||
|
targets.value,
|
||||||
|
unrefElement(root as MaybeComputedElementRef),
|
||||||
|
toValue(rootMargin),
|
||||||
|
toValue(threshold),
|
||||||
|
isActive.value,
|
||||||
|
] as const,
|
||||||
|
([els, rootEl, margin, thresh, active]) => {
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
if (!active || !els.length)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(callback, {
|
||||||
|
root: (rootEl as Element | null) ?? (root as Document | undefined),
|
||||||
|
rootMargin: margin,
|
||||||
|
threshold: thresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const el of els)
|
||||||
|
observer.observe(el);
|
||||||
|
|
||||||
|
cleanup = () => {
|
||||||
|
observer.disconnect();
|
||||||
|
cleanup = noop;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ immediate: true, flush: 'post' },
|
||||||
|
)
|
||||||
|
: noop;
|
||||||
|
|
||||||
|
const resume = (): void => {
|
||||||
|
isActive.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pause = (): void => {
|
||||||
|
cleanup();
|
||||||
|
isActive.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = (): void => {
|
||||||
|
cleanup();
|
||||||
|
stopWatch();
|
||||||
|
isActive.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
tryOnScopeDispose(stop);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSupported,
|
||||||
|
isActive: readonly(isActive),
|
||||||
|
pause,
|
||||||
|
resume,
|
||||||
|
stop,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { defineComponent, effectScope, nextTick, ref } from 'vue';
|
import { defineComponent, effectScope, nextTick, ref } from 'vue';
|
||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
import { useIntervalFn } from '.';
|
import { useIntervalFn } from '.';
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope, nextTick } from 'vue';
|
||||||
|
import { useKeyModifier } from '.';
|
||||||
|
import type { KeyModifier } from '.';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch an event on `document` whose `getModifierState(modifier)` resolves to `active`.
|
||||||
|
* jsdom does not track real modifier state, so we stub the method per event.
|
||||||
|
*/
|
||||||
|
function dispatchModifier(
|
||||||
|
type: string,
|
||||||
|
active: boolean,
|
||||||
|
modifier: KeyModifier = 'Shift',
|
||||||
|
withGetModifierState = true,
|
||||||
|
) {
|
||||||
|
const event = new Event(type) as Event & {
|
||||||
|
getModifierState?: (key: string) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (withGetModifierState) {
|
||||||
|
event.getModifierState = (key: string) => (key === modifier ? active : false);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(useKeyModifier, () => {
|
||||||
|
it('defaults to null until the first matching event', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let state: ReturnType<typeof useKeyModifier>;
|
||||||
|
scope.run(() => {
|
||||||
|
state = useKeyModifier('Shift');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state!.value).toBeNull();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects a provided initial value', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let state: ReturnType<typeof useKeyModifier<boolean>>;
|
||||||
|
scope.run(() => {
|
||||||
|
state = useKeyModifier('Shift', { initial: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state!.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates when a default event reports the modifier active', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let state: ReturnType<typeof useKeyModifier>;
|
||||||
|
scope.run(() => {
|
||||||
|
state = useKeyModifier('Shift');
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatchModifier('keydown', true, 'Shift');
|
||||||
|
await nextTick();
|
||||||
|
expect(state!.value).toBeTruthy();
|
||||||
|
|
||||||
|
dispatchModifier('keyup', false, 'Shift');
|
||||||
|
await nextTick();
|
||||||
|
expect(state!.value).toBeFalsy();
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks each modifier independently', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let caps: ReturnType<typeof useKeyModifier>;
|
||||||
|
scope.run(() => {
|
||||||
|
caps = useKeyModifier('CapsLock');
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatchModifier('keydown', true, 'CapsLock');
|
||||||
|
await nextTick();
|
||||||
|
expect(caps!.value).toBeTruthy();
|
||||||
|
|
||||||
|
// An event reporting a different modifier (Shift) must not flip CapsLock
|
||||||
|
dispatchModifier('keydown', true, 'Shift');
|
||||||
|
await nextTick();
|
||||||
|
expect(caps!.value).toBeFalsy();
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only listens on the configured events', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let state: ReturnType<typeof useKeyModifier>;
|
||||||
|
scope.run(() => {
|
||||||
|
state = useKeyModifier('Shift', { events: ['keydown'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatchModifier('keyup', true, 'Shift');
|
||||||
|
await nextTick();
|
||||||
|
expect(state!.value).toBeNull();
|
||||||
|
|
||||||
|
dispatchModifier('keydown', true, 'Shift');
|
||||||
|
await nextTick();
|
||||||
|
expect(state!.value).toBeTruthy();
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores events without getModifierState', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let state: ReturnType<typeof useKeyModifier>;
|
||||||
|
scope.run(() => {
|
||||||
|
state = useKeyModifier('Shift', { initial: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatchModifier('keydown', true, 'Shift', false);
|
||||||
|
await nextTick();
|
||||||
|
expect(state!.value).toBeFalsy();
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes its listeners when the scope is disposed', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let state: ReturnType<typeof useKeyModifier>;
|
||||||
|
scope.run(() => {
|
||||||
|
state = useKeyModifier('Shift');
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
|
||||||
|
dispatchModifier('keydown', true, 'Shift');
|
||||||
|
await nextTick();
|
||||||
|
expect(state!.value).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a custom document and listens on it', async () => {
|
||||||
|
const listeners = new Map<string, Set<EventListener>>();
|
||||||
|
const fakeDocument = {
|
||||||
|
addEventListener(type: string, listener: EventListener) {
|
||||||
|
if (!listeners.has(type))
|
||||||
|
listeners.set(type, new Set());
|
||||||
|
listeners.get(type)!.add(listener);
|
||||||
|
},
|
||||||
|
removeEventListener(type: string, listener: EventListener) {
|
||||||
|
listeners.get(type)?.delete(listener);
|
||||||
|
},
|
||||||
|
} as unknown as Document;
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let state: ReturnType<typeof useKeyModifier>;
|
||||||
|
scope.run(() => {
|
||||||
|
state = useKeyModifier('Meta', { document: fakeDocument });
|
||||||
|
});
|
||||||
|
|
||||||
|
const event = { getModifierState: (k: string) => k === 'Meta' } as unknown as KeyboardEvent;
|
||||||
|
listeners.get('keydown')!.forEach(fn => fn(event));
|
||||||
|
await nextTick();
|
||||||
|
expect(state!.value).toBeTruthy();
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op under SSR (no document)', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let state: ReturnType<typeof useKeyModifier>;
|
||||||
|
scope.run(() => {
|
||||||
|
state = useKeyModifier('Shift', { document: undefined, initial: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state!.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { shallowRef } from 'vue';
|
||||||
|
import type { ShallowRef } from 'vue';
|
||||||
|
import { isFunction } from '@robonen/stdlib';
|
||||||
|
import { defaultDocument } from '@/types';
|
||||||
|
import type { ConfigurableDocument } from '@/types';
|
||||||
|
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||||
|
import type { DocumentEventName } from '@/composables/browser/useEventListener';
|
||||||
|
|
||||||
|
export type KeyModifier
|
||||||
|
= | 'Alt'
|
||||||
|
| 'AltGraph'
|
||||||
|
| 'CapsLock'
|
||||||
|
| 'Control'
|
||||||
|
| 'Fn'
|
||||||
|
| 'FnLock'
|
||||||
|
| 'Meta'
|
||||||
|
| 'NumLock'
|
||||||
|
| 'ScrollLock'
|
||||||
|
| 'Shift'
|
||||||
|
| 'Symbol'
|
||||||
|
| 'SymbolLock';
|
||||||
|
|
||||||
|
const DEFAULT_EVENTS: DocumentEventName[] = ['mousedown', 'mouseup', 'keydown', 'keyup'];
|
||||||
|
|
||||||
|
export interface UseKeyModifierOptions<Initial> extends ConfigurableDocument {
|
||||||
|
/**
|
||||||
|
* Event names that will prompt an update to the modifier state.
|
||||||
|
*
|
||||||
|
* @default ['mousedown', 'mouseup', 'keydown', 'keyup']
|
||||||
|
*/
|
||||||
|
events?: DocumentEventName[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initial value of the returned ref.
|
||||||
|
*
|
||||||
|
* @default null
|
||||||
|
*/
|
||||||
|
initial?: Initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseKeyModifierReturn<Initial> = ShallowRef<Initial extends boolean ? boolean : boolean | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useKeyModifier
|
||||||
|
* @category Browser
|
||||||
|
* @description Reactive state of a keyboard modifier (CapsLock, NumLock, Shift, Control, Alt, Meta, ...) tracked via `KeyboardEvent.getModifierState`.
|
||||||
|
*
|
||||||
|
* @param {KeyModifier} modifier The modifier key to observe
|
||||||
|
* @param {UseKeyModifierOptions} [options={}] Options (`events` to listen on, `initial` value, custom `document`)
|
||||||
|
* @returns {UseKeyModifierReturn} A shallow ref holding the current modifier state (`null` until the first matching event)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const capsLock = useKeyModifier('CapsLock');
|
||||||
|
* watch(capsLock, (on) => {
|
||||||
|
* if (on) showCapsLockWarning();
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const shift = useKeyModifier('Shift', { initial: false, events: ['keydown', 'keyup'] });
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useKeyModifier<Initial extends boolean | null = null>(
|
||||||
|
modifier: KeyModifier,
|
||||||
|
options: UseKeyModifierOptions<Initial> = {},
|
||||||
|
): UseKeyModifierReturn<Initial> {
|
||||||
|
const {
|
||||||
|
events = DEFAULT_EVENTS,
|
||||||
|
document = defaultDocument,
|
||||||
|
initial = null,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const state = shallowRef(initial) as ShallowRef<boolean | null>;
|
||||||
|
|
||||||
|
if (document) {
|
||||||
|
useEventListener(document, events, (event: KeyboardEvent | MouseEvent) => {
|
||||||
|
if (isFunction(event.getModifierState))
|
||||||
|
state.value = event.getModifierState(modifier);
|
||||||
|
}, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return state as UseKeyModifierReturn<Initial>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope, nextTick, toValue, watch } from 'vue';
|
||||||
|
import { DefaultMagicKeysAliasMap, useMagicKeys } from '.';
|
||||||
|
|
||||||
|
function keydown(key: string, init: KeyboardEventInit = {}, target: EventTarget = globalThis) {
|
||||||
|
target.dispatchEvent(new KeyboardEvent('keydown', { key, ...init }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyup(key: string, init: KeyboardEventInit = {}, target: EventTarget = globalThis) {
|
||||||
|
target.dispatchEvent(new KeyboardEvent('keyup', { key, ...init }));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(useMagicKeys, () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks a single pressed key', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
|
||||||
|
scope.run(() => {
|
||||||
|
const keys = useMagicKeys();
|
||||||
|
|
||||||
|
expect(keys.a!.value).toBeFalsy();
|
||||||
|
|
||||||
|
keydown('a');
|
||||||
|
expect(keys.a!.value).toBeTruthy();
|
||||||
|
|
||||||
|
keyup('a');
|
||||||
|
expect(keys.a!.value).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes the current Set of pressed keys', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
|
||||||
|
scope.run(() => {
|
||||||
|
const keys = useMagicKeys();
|
||||||
|
|
||||||
|
keydown('a');
|
||||||
|
keydown('b');
|
||||||
|
|
||||||
|
expect([...keys.current]).toEqual(['a', 'b']);
|
||||||
|
|
||||||
|
keyup('a');
|
||||||
|
expect([...keys.current]).toEqual(['b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports combinations via proxy property (ctrl+a)', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
|
||||||
|
scope.run(() => {
|
||||||
|
const keys = useMagicKeys();
|
||||||
|
const ctrlA = keys['ctrl+a']!;
|
||||||
|
|
||||||
|
expect(ctrlA.value).toBeFalsy();
|
||||||
|
|
||||||
|
keydown('Control');
|
||||||
|
expect(ctrlA.value).toBeFalsy();
|
||||||
|
|
||||||
|
keydown('a');
|
||||||
|
expect(ctrlA.value).toBeTruthy();
|
||||||
|
|
||||||
|
keyup('a');
|
||||||
|
expect(ctrlA.value).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports combos with _ and - delimiters', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
|
||||||
|
scope.run(() => {
|
||||||
|
const keys = useMagicKeys();
|
||||||
|
const underscore = keys.ctrl_a!;
|
||||||
|
const dash = keys['ctrl-a']!;
|
||||||
|
|
||||||
|
keydown('Control');
|
||||||
|
keydown('a');
|
||||||
|
|
||||||
|
expect(underscore.value).toBeTruthy();
|
||||||
|
expect(dash.value).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves aliases (cmd -> meta, esc -> escape)', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
|
||||||
|
scope.run(() => {
|
||||||
|
const keys = useMagicKeys();
|
||||||
|
|
||||||
|
keydown('Meta');
|
||||||
|
expect(keys.cmd!.value).toBeTruthy();
|
||||||
|
expect(keys.command!.value).toBeTruthy();
|
||||||
|
expect(keys.meta!.value).toBeTruthy();
|
||||||
|
|
||||||
|
keyup('Meta');
|
||||||
|
|
||||||
|
keydown('Escape');
|
||||||
|
expect(keys.esc!.value).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects a custom aliasMap', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
|
||||||
|
scope.run(() => {
|
||||||
|
const keys = useMagicKeys({ aliasMap: { fire: 'f' } });
|
||||||
|
|
||||||
|
keydown('f');
|
||||||
|
expect(keys.fire!.value).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is case-insensitive on property access', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
|
||||||
|
scope.run(() => {
|
||||||
|
const keys = useMagicKeys();
|
||||||
|
|
||||||
|
keydown('a');
|
||||||
|
expect(keys.A!.value).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks event.code as well as key', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
|
||||||
|
scope.run(() => {
|
||||||
|
const keys = useMagicKeys();
|
||||||
|
|
||||||
|
keydown('a', { code: 'KeyA' });
|
||||||
|
expect((keys as any).keya.value).toBeTruthy();
|
||||||
|
|
||||||
|
keyup('a', { code: 'KeyA' });
|
||||||
|
expect((keys as any).keya.value).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reactive mode returns plain booleans', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
|
||||||
|
scope.run(() => {
|
||||||
|
const keys = useMagicKeys({ reactive: true });
|
||||||
|
|
||||||
|
expect(keys.a).toBeFalsy();
|
||||||
|
|
||||||
|
keydown('a');
|
||||||
|
expect(keys.a).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reset() clears the current Set and all refs', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
|
||||||
|
scope.run(() => {
|
||||||
|
const keys = useMagicKeys();
|
||||||
|
|
||||||
|
keydown('a');
|
||||||
|
keydown('b');
|
||||||
|
expect(keys.a!.value).toBeTruthy();
|
||||||
|
|
||||||
|
keys.reset();
|
||||||
|
|
||||||
|
expect(keys.a!.value).toBeFalsy();
|
||||||
|
expect(keys.b!.value).toBeFalsy();
|
||||||
|
expect(keys.current.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets on blur so keys do not stick', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
|
||||||
|
scope.run(() => {
|
||||||
|
const keys = useMagicKeys();
|
||||||
|
|
||||||
|
keydown('a');
|
||||||
|
expect(keys.a!.value).toBeTruthy();
|
||||||
|
|
||||||
|
globalThis.dispatchEvent(new Event('blur'));
|
||||||
|
expect(keys.a!.value).toBeFalsy();
|
||||||
|
expect(keys.current.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears meta-dependent keys when Meta is released (macOS behaviour)', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
|
||||||
|
scope.run(() => {
|
||||||
|
const keys = useMagicKeys();
|
||||||
|
|
||||||
|
// Press Cmd, then 'a' while Cmd held (getModifierState reports Meta)
|
||||||
|
keydown('Meta');
|
||||||
|
keydown('a', { metaKey: true });
|
||||||
|
|
||||||
|
expect(keys.a!.value).toBeTruthy();
|
||||||
|
expect(keys.current.has('a')).toBeTruthy();
|
||||||
|
|
||||||
|
// Releasing Meta should drop 'a' too (no keyup fires for it on macOS)
|
||||||
|
keyup('Meta');
|
||||||
|
|
||||||
|
expect(keys.meta!.value).toBeFalsy();
|
||||||
|
expect(keys.a!.value).toBeFalsy();
|
||||||
|
expect(keys.current.has('a')).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invokes onEventFired for keydown and keyup', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
|
||||||
|
scope.run(() => {
|
||||||
|
const onEventFired = vi.fn();
|
||||||
|
useMagicKeys({ onEventFired });
|
||||||
|
|
||||||
|
keydown('a');
|
||||||
|
keyup('a');
|
||||||
|
|
||||||
|
expect(onEventFired).toHaveBeenCalledTimes(2);
|
||||||
|
expect(onEventFired.mock.calls[0]![0]).toBeInstanceOf(KeyboardEvent);
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attaches to a custom target', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
|
||||||
|
scope.run(() => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const keys = useMagicKeys({ target: el });
|
||||||
|
|
||||||
|
keydown('a', {}, el);
|
||||||
|
expect(keys.a!.value).toBeTruthy();
|
||||||
|
|
||||||
|
// window events should not affect a custom target
|
||||||
|
keyup('a', {}, el);
|
||||||
|
keydown('b');
|
||||||
|
expect(keys.b!.value).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('combination refs are reactive (computed)', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
|
||||||
|
await scope.run(async () => {
|
||||||
|
const keys = useMagicKeys();
|
||||||
|
const combo = keys['shift+a']!;
|
||||||
|
|
||||||
|
const seen: boolean[] = [];
|
||||||
|
watch(combo, v => seen.push(v));
|
||||||
|
|
||||||
|
keydown('Shift');
|
||||||
|
keydown('a');
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(toValue(combo)).toBeTruthy();
|
||||||
|
expect(seen).toContain(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes DefaultMagicKeysAliasMap with expected aliases', () => {
|
||||||
|
expect(DefaultMagicKeysAliasMap.ctrl).toBe('control');
|
||||||
|
expect(DefaultMagicKeysAliasMap.cmd).toBe('meta');
|
||||||
|
expect(DefaultMagicKeysAliasMap.command).toBe('meta');
|
||||||
|
expect(DefaultMagicKeysAliasMap.option).toBe('alt');
|
||||||
|
expect(DefaultMagicKeysAliasMap.up).toBe('arrowup');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw and returns an object when window is absent (SSR path)', () => {
|
||||||
|
// No target available -> listeners are skipped, refs still usable
|
||||||
|
const keys = useMagicKeys({ target: undefined as unknown as EventTarget });
|
||||||
|
|
||||||
|
expect(keys.current.size).toBe(0);
|
||||||
|
// accessing a key lazily creates a ref defaulting to false
|
||||||
|
expect(keys.a!.value).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import type { AnyFunction } from '@robonen/stdlib';
|
||||||
|
import { isFunction, noop } from '@robonen/stdlib';
|
||||||
|
import type { ComputedRef, Ref, ShallowRef } from 'vue';
|
||||||
|
import { computed, reactive, shallowRef, toValue } from 'vue';
|
||||||
|
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||||
|
import { defaultWindow } from '@/types';
|
||||||
|
|
||||||
|
export type UseMagicKeysAliasMap = Readonly<Record<string, string>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default lowercase alias map: maps common shorthand key names to their
|
||||||
|
* canonical `KeyboardEvent.key` (lowercased) equivalents.
|
||||||
|
*/
|
||||||
|
export const DefaultMagicKeysAliasMap: UseMagicKeysAliasMap = /* #__PURE__ */ {
|
||||||
|
ctrl: 'control',
|
||||||
|
command: 'meta',
|
||||||
|
cmd: 'meta',
|
||||||
|
option: 'alt',
|
||||||
|
opt: 'alt',
|
||||||
|
up: 'arrowup',
|
||||||
|
down: 'arrowdown',
|
||||||
|
left: 'arrowleft',
|
||||||
|
right: 'arrowright',
|
||||||
|
esc: 'escape',
|
||||||
|
space: ' ',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface UseMagicKeysOptions<Reactive extends boolean> {
|
||||||
|
/**
|
||||||
|
* Return a reactive object instead of an object of refs
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
reactive?: Reactive;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event target to attach the keyboard listeners to
|
||||||
|
*
|
||||||
|
* @default window
|
||||||
|
*/
|
||||||
|
target?: EventTarget;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alias map for keys, all keys should be lowercase
|
||||||
|
* { foo: 'bar' } means that pressing `bar` will also trigger `foo`
|
||||||
|
*
|
||||||
|
* @default DefaultMagicKeysAliasMap
|
||||||
|
*/
|
||||||
|
aliasMap?: UseMagicKeysAliasMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register passive listeners
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
passive?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom event handler for the keyboard event. Useful for preventing default
|
||||||
|
* behaviour for certain key combos. Called on every keydown and keyup.
|
||||||
|
*/
|
||||||
|
onEventFired?: (event: KeyboardEvent) => void | boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseMagicKeysReturn {
|
||||||
|
/**
|
||||||
|
* A Set of currently pressed keys (lowercase canonical names)
|
||||||
|
*/
|
||||||
|
current: Set<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset all tracked keys to `false` and clear the current Set
|
||||||
|
*/
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MagicKeys<Reactive extends boolean> = Readonly<
|
||||||
|
Omit<
|
||||||
|
Record<string, Reactive extends true ? boolean : ComputedRef<boolean>>,
|
||||||
|
keyof UseMagicKeysReturn
|
||||||
|
>
|
||||||
|
& UseMagicKeysReturn
|
||||||
|
>;
|
||||||
|
|
||||||
|
type KeyRefs = Record<string, Ref<boolean> | ShallowRef<boolean> | ComputedRef<boolean>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useMagicKeys
|
||||||
|
* @category Browser
|
||||||
|
* @description Reactive keys pressed state, with magical combination keys support via a Proxy.
|
||||||
|
* Access combinations directly as properties, e.g. `keys['ctrl+a']` or `keys.ctrl_a`.
|
||||||
|
*
|
||||||
|
* @param {UseMagicKeysOptions} [options] Configuration options
|
||||||
|
* @returns {MagicKeys} A Proxy of refs (or reactive booleans) plus `current` Set and `reset`
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const keys = useMagicKeys();
|
||||||
|
* const ctrlA = keys['ctrl+a'];
|
||||||
|
* watch(ctrlA, v => { if (v) console.log('Ctrl + A pressed'); });
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { ctrl, a, current } = useMagicKeys({ reactive: true });
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useMagicKeys(options?: UseMagicKeysOptions<false>): MagicKeys<false>;
|
||||||
|
export function useMagicKeys(options: UseMagicKeysOptions<true>): MagicKeys<true>;
|
||||||
|
export function useMagicKeys(options: UseMagicKeysOptions<boolean> = {}): any {
|
||||||
|
const {
|
||||||
|
reactive: useReactive = false,
|
||||||
|
target = defaultWindow,
|
||||||
|
aliasMap = DefaultMagicKeysAliasMap,
|
||||||
|
passive = true,
|
||||||
|
onEventFired = noop as AnyFunction,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const current = reactive(new Set<string>());
|
||||||
|
const usedKeys = new Set<string>();
|
||||||
|
// Keys pressed while Meta is held — on macOS, keyup is suppressed for other
|
||||||
|
// keys while Cmd is down, so we clear them when Meta is released.
|
||||||
|
const metaDeps = new Set<string>();
|
||||||
|
|
||||||
|
function reset(): void {
|
||||||
|
current.clear();
|
||||||
|
|
||||||
|
for (const key of usedKeys)
|
||||||
|
setRefs(key, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj: UseMagicKeysReturn = {
|
||||||
|
current,
|
||||||
|
reset,
|
||||||
|
};
|
||||||
|
|
||||||
|
const refs: KeyRefs = useReactive ? reactive(obj as any) : (obj as any);
|
||||||
|
|
||||||
|
function setRefs(key: string, value: boolean): void {
|
||||||
|
// Touch the proxy so the ref is materialized for keys we actually track,
|
||||||
|
// even if the consumer hasn't accessed them yet.
|
||||||
|
if (!(key in refs))
|
||||||
|
void (proxy as any)[key];
|
||||||
|
|
||||||
|
if (key in refs) {
|
||||||
|
if (useReactive)
|
||||||
|
(refs as any)[key] = value;
|
||||||
|
else
|
||||||
|
(refs[key] as Ref<boolean>).value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRefs(event: KeyboardEvent, value: boolean): void {
|
||||||
|
const key = event.key?.toLowerCase();
|
||||||
|
const code = event.code?.toLowerCase();
|
||||||
|
const values = [code, key].filter(Boolean) as string[];
|
||||||
|
|
||||||
|
if (!key)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (value)
|
||||||
|
current.add(key);
|
||||||
|
else
|
||||||
|
current.delete(key);
|
||||||
|
|
||||||
|
for (const k of values) {
|
||||||
|
usedKeys.add(k);
|
||||||
|
setRefs(k, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'meta' && !value) {
|
||||||
|
// Cmd released on macOS: clear keys that were pressed during the chord
|
||||||
|
metaDeps.forEach((k) => {
|
||||||
|
current.delete(k);
|
||||||
|
setRefs(k, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
metaDeps.clear();
|
||||||
|
}
|
||||||
|
else if (isFunction(event.getModifierState) && event.getModifierState('Meta') && value) {
|
||||||
|
[...current, ...values].forEach(k => metaDeps.add(k));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
useEventListener(target, 'keydown', (event: KeyboardEvent) => {
|
||||||
|
updateRefs(event, true);
|
||||||
|
return onEventFired(event);
|
||||||
|
}, { passive });
|
||||||
|
|
||||||
|
useEventListener(target, 'keyup', (event: KeyboardEvent) => {
|
||||||
|
updateRefs(event, false);
|
||||||
|
return onEventFired(event);
|
||||||
|
}, { passive });
|
||||||
|
|
||||||
|
// Reset on blur so keys don't "stick" when focus leaves the page
|
||||||
|
useEventListener('blur', reset, { passive: true });
|
||||||
|
useEventListener('focus', reset, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxy = new Proxy(refs, {
|
||||||
|
get(target, prop, receiver) {
|
||||||
|
if (typeof prop !== 'string')
|
||||||
|
return Reflect.get(target, prop, receiver);
|
||||||
|
|
||||||
|
prop = prop.toLowerCase();
|
||||||
|
|
||||||
|
// alias resolution
|
||||||
|
if (prop in aliasMap)
|
||||||
|
prop = aliasMap[prop] as string;
|
||||||
|
|
||||||
|
// lazily create tracking ref for combos and single keys
|
||||||
|
if (!(prop in refs)) {
|
||||||
|
if (/[+_-]/.test(prop)) {
|
||||||
|
const keys = prop.split(/[+_-]/g).map((i: string) => i.trim());
|
||||||
|
refs[prop] = computed(() => keys.map(key => toValue((proxy as any)[key])).every(Boolean));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
refs[prop] = shallowRef(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = Reflect.get(target, prop, receiver);
|
||||||
|
return useReactive ? toValue(r) : r;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return proxy as any;
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { defineComponent, effectScope, isReadonly, nextTick, ref } from 'vue';
|
||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import { useMediaQuery } from '.';
|
||||||
|
|
||||||
|
type Listener = (event: { matches: boolean }) => void;
|
||||||
|
|
||||||
|
interface StubMql {
|
||||||
|
readonly matches: boolean;
|
||||||
|
media: string;
|
||||||
|
addEventListener: (type: string, cb: Listener) => void;
|
||||||
|
removeEventListener: (type: string, cb: Listener) => void;
|
||||||
|
dispatch: (value: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMql(initialMatches: boolean, media = ''): StubMql {
|
||||||
|
const listeners = new Set<Listener>();
|
||||||
|
let matches = initialMatches;
|
||||||
|
|
||||||
|
return {
|
||||||
|
get matches() {
|
||||||
|
return matches;
|
||||||
|
},
|
||||||
|
media,
|
||||||
|
addEventListener: (_: string, cb: Listener) => listeners.add(cb),
|
||||||
|
removeEventListener: (_: string, cb: Listener) => listeners.delete(cb),
|
||||||
|
dispatch(value: boolean) {
|
||||||
|
matches = value;
|
||||||
|
for (const cb of listeners) cb({ matches: value });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubMatchMedia(initialMatches: boolean) {
|
||||||
|
const mql = makeMql(initialMatches);
|
||||||
|
vi.stubGlobal('matchMedia', vi.fn(() => mql));
|
||||||
|
return mql;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a different MediaQueryList per query string, so reactive query swaps can be exercised. */
|
||||||
|
function stubMatchMediaByQuery(map: Record<string, StubMql>, fallbackMatches = false) {
|
||||||
|
const spy = vi.fn((query: string) => map[query] ?? makeMql(fallbackMatches, query));
|
||||||
|
vi.stubGlobal('matchMedia', spy);
|
||||||
|
return spy;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(useMediaQuery, () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('matchMedia', undefined);
|
||||||
|
});
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('reflects the initial match', async () => {
|
||||||
|
stubMatchMedia(true);
|
||||||
|
const scope = effectScope();
|
||||||
|
let matches: ReturnType<typeof useMediaQuery>;
|
||||||
|
scope.run(() => {
|
||||||
|
matches = useMediaQuery('(min-width: 100px)');
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
expect(matches!.value).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates when the media query changes', async () => {
|
||||||
|
const mql = stubMatchMedia(false);
|
||||||
|
const scope = effectScope();
|
||||||
|
let matches: ReturnType<typeof useMediaQuery>;
|
||||||
|
scope.run(() => {
|
||||||
|
matches = useMediaQuery('(min-width: 100px)');
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(matches!.value).toBeFalsy();
|
||||||
|
mql.dispatch(true);
|
||||||
|
expect(matches!.value).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when matchMedia is unsupported (SSR)', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let matches: ReturnType<typeof useMediaQuery>;
|
||||||
|
scope.run(() => {
|
||||||
|
matches = useMediaQuery('(min-width: 100px)');
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
expect(matches!.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a readonly ref', async () => {
|
||||||
|
stubMatchMedia(true);
|
||||||
|
const scope = effectScope();
|
||||||
|
let matches: ReturnType<typeof useMediaQuery>;
|
||||||
|
scope.run(() => {
|
||||||
|
matches = useMediaQuery('(min-width: 100px)');
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
expect(isReadonly(matches!)).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reacts to a changing query ref by re-binding to the new MediaQueryList', async () => {
|
||||||
|
const small = makeMql(false, '(min-width: 100px)');
|
||||||
|
const large = makeMql(true, '(min-width: 1000px)');
|
||||||
|
stubMatchMediaByQuery({
|
||||||
|
'(min-width: 100px)': small,
|
||||||
|
'(min-width: 1000px)': large,
|
||||||
|
});
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
const query = ref('(min-width: 100px)');
|
||||||
|
let matches: ReturnType<typeof useMediaQuery>;
|
||||||
|
scope.run(() => {
|
||||||
|
matches = useMediaQuery(query);
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
expect(matches!.value).toBeFalsy();
|
||||||
|
|
||||||
|
query.value = '(min-width: 1000px)';
|
||||||
|
await nextTick();
|
||||||
|
expect(matches!.value).toBeTruthy();
|
||||||
|
|
||||||
|
// The listener should follow the new MediaQueryList, not the old one.
|
||||||
|
large.dispatch(false);
|
||||||
|
expect(matches!.value).toBeFalsy();
|
||||||
|
// The old MediaQueryList should no longer affect the result.
|
||||||
|
small.dispatch(true);
|
||||||
|
expect(matches!.value).toBeFalsy();
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('also accepts a getter for the query', async () => {
|
||||||
|
stubMatchMedia(true);
|
||||||
|
const scope = effectScope();
|
||||||
|
let matches: ReturnType<typeof useMediaQuery>;
|
||||||
|
scope.run(() => {
|
||||||
|
matches = useMediaQuery(() => '(min-width: 100px)');
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
expect(matches!.value).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ssrWidth', () => {
|
||||||
|
it('resolves min-width against ssrWidth when matchMedia is unsupported', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let wide: ReturnType<typeof useMediaQuery>;
|
||||||
|
let narrow: ReturnType<typeof useMediaQuery>;
|
||||||
|
scope.run(() => {
|
||||||
|
wide = useMediaQuery('(min-width: 1024px)', { ssrWidth: 1280 });
|
||||||
|
narrow = useMediaQuery('(min-width: 1024px)', { ssrWidth: 800 });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
expect(wide!.value).toBeTruthy();
|
||||||
|
expect(narrow!.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves max-width against ssrWidth', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let matches: ReturnType<typeof useMediaQuery>;
|
||||||
|
scope.run(() => {
|
||||||
|
matches = useMediaQuery('(max-width: 768px)', { ssrWidth: 500 });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
expect(matches!.value).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles a min-width/max-width range', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let inRange: ReturnType<typeof useMediaQuery>;
|
||||||
|
let outOfRange: ReturnType<typeof useMediaQuery>;
|
||||||
|
scope.run(() => {
|
||||||
|
inRange = useMediaQuery('(min-width: 600px) and (max-width: 1200px)', { ssrWidth: 900 });
|
||||||
|
outOfRange = useMediaQuery('(min-width: 600px) and (max-width: 1200px)', { ssrWidth: 1500 });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
expect(inRange!.value).toBeTruthy();
|
||||||
|
expect(outOfRange!.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects `not all` negation', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let matches: ReturnType<typeof useMediaQuery>;
|
||||||
|
scope.run(() => {
|
||||||
|
matches = useMediaQuery('not all and (min-width: 1024px)', { ssrWidth: 1280 });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
expect(matches!.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OR-combines comma-separated queries', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let matches: ReturnType<typeof useMediaQuery>;
|
||||||
|
scope.run(() => {
|
||||||
|
matches = useMediaQuery('(min-width: 2000px), (max-width: 900px)', { ssrWidth: 800 });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
expect(matches!.value).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts em/rem units using a 16px root', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let matches: ReturnType<typeof useMediaQuery>;
|
||||||
|
// 48em === 768px; ssrWidth 800 >= 768 → matches
|
||||||
|
scope.run(() => {
|
||||||
|
matches = useMediaQuery('(min-width: 48em)', { ssrWidth: 800 });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
expect(matches!.value).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers the real matchMedia over ssrWidth once mounted', async () => {
|
||||||
|
// matchMedia reports false even though ssrWidth would resolve true.
|
||||||
|
stubMatchMedia(false);
|
||||||
|
let matches: ReturnType<typeof useMediaQuery>;
|
||||||
|
const wrapper = mount(defineComponent({
|
||||||
|
setup() {
|
||||||
|
matches = useMediaQuery('(min-width: 1024px)', { ssrWidth: 1280 });
|
||||||
|
return () => null;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
await nextTick();
|
||||||
|
// After mount, isSupported becomes true and the real (false) result wins.
|
||||||
|
expect(matches!.value).toBeFalsy();
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses ssrWidth on the first render before mount, then re-evaluates', async () => {
|
||||||
|
// Real matchMedia is available, but the very first synchronous effect
|
||||||
|
// run (pre-mount) should still resolve via ssrWidth to avoid flicker.
|
||||||
|
stubMatchMedia(false);
|
||||||
|
let matches: ReturnType<typeof useMediaQuery>;
|
||||||
|
const scope = effectScope();
|
||||||
|
// Without a mounted component, isSupported stays false, so ssrWidth wins.
|
||||||
|
scope.run(() => {
|
||||||
|
matches = useMediaQuery('(min-width: 1024px)', { ssrWidth: 1280 });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
expect(matches!.value).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { computed, shallowRef, toValue, watchEffect } from 'vue';
|
||||||
|
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
|
||||||
|
import { isFunction, isNumber } from '@robonen/stdlib';
|
||||||
|
import { defaultWindow } from '@/types';
|
||||||
|
import type { ConfigurableWindow } from '@/types';
|
||||||
|
import { useSupported } from '@/composables/browser/useSupported';
|
||||||
|
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||||
|
|
||||||
|
export interface UseMediaQueryOptions extends ConfigurableWindow {
|
||||||
|
/**
|
||||||
|
* The viewport width (in pixels) assumed during SSR, used to resolve
|
||||||
|
* `min-width` / `max-width` queries before `window.matchMedia` is available.
|
||||||
|
*
|
||||||
|
* When provided, the composable returns a best-effort match on the server
|
||||||
|
* (and the first client render) instead of always `false`, avoiding hydration
|
||||||
|
* flicker for width-based queries. Ignored once `matchMedia` is supported.
|
||||||
|
*
|
||||||
|
* @default undefined
|
||||||
|
*/
|
||||||
|
ssrWidth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a CSS length token (e.g. `"1024px"`, `"48em"`, `"30rem"`) to pixels.
|
||||||
|
* Falls back to treating `em`/`rem` as the conventional 16px root size.
|
||||||
|
*/
|
||||||
|
function pxValue(value: string): number {
|
||||||
|
const number = Number.parseFloat(value);
|
||||||
|
|
||||||
|
if (Number.isNaN(number))
|
||||||
|
return Number.NaN;
|
||||||
|
|
||||||
|
if (/(?:em|rem)\s*$/i.test(value))
|
||||||
|
return number * 16;
|
||||||
|
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort evaluation of `min-width` / `max-width` media queries against a
|
||||||
|
* known viewport width, for SSR. Comma-separated queries are OR-combined and
|
||||||
|
* `not all` negation is respected. Returns `false` for queries we can't resolve.
|
||||||
|
*/
|
||||||
|
function matchSsrWidth(query: string, width: number): boolean {
|
||||||
|
return query.split(',').some((part) => {
|
||||||
|
const not = part.includes('not all');
|
||||||
|
const minWidth = part.match(/\(\s*min-width:\s*(-?\d+(?:\.\d*)?[a-z%]+\s*)\)/);
|
||||||
|
const maxWidth = part.match(/\(\s*max-width:\s*(-?\d+(?:\.\d*)?[a-z%]+\s*)\)/);
|
||||||
|
|
||||||
|
let result = Boolean(minWidth || maxWidth);
|
||||||
|
|
||||||
|
if (minWidth && result)
|
||||||
|
result = width >= pxValue(minWidth[1]!);
|
||||||
|
|
||||||
|
if (maxWidth && result)
|
||||||
|
result = width <= pxValue(maxWidth[1]!);
|
||||||
|
|
||||||
|
return not ? !result : result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useMediaQuery
|
||||||
|
* @category Browser
|
||||||
|
* @description Reactive `window.matchMedia`. SSR-safe, reactive to the query, and
|
||||||
|
* with optional SSR width resolution for `min-width` / `max-width` queries.
|
||||||
|
*
|
||||||
|
* @param {MaybeRefOrGetter<string>} query The media query (can be reactive)
|
||||||
|
* @param {UseMediaQueryOptions} [options={}] Options (custom `window`, `ssrWidth`)
|
||||||
|
* @returns {ComputedRef<boolean>} Readonly ref of whether the query currently matches
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const isLarge = useMediaQuery('(min-width: 1024px)');
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Resolve width queries during SSR to avoid hydration flicker
|
||||||
|
* const isWide = useMediaQuery('(min-width: 1024px)', { ssrWidth: 1280 });
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useMediaQuery(
|
||||||
|
query: MaybeRefOrGetter<string>,
|
||||||
|
options: UseMediaQueryOptions = {},
|
||||||
|
): ComputedRef<boolean> {
|
||||||
|
const { window = defaultWindow, ssrWidth } = options;
|
||||||
|
|
||||||
|
const isSupported = useSupported(() =>
|
||||||
|
window && 'matchMedia' in window && isFunction(window.matchMedia));
|
||||||
|
|
||||||
|
const ssrSupport = shallowRef(isNumber(ssrWidth));
|
||||||
|
|
||||||
|
const mediaQuery = shallowRef<MediaQueryList | undefined>();
|
||||||
|
const matches = shallowRef(false);
|
||||||
|
|
||||||
|
const handler = (event: MediaQueryListEvent) => {
|
||||||
|
matches.value = event.matches;
|
||||||
|
};
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
// Resolve width-based queries from `ssrWidth` until the real API is ready.
|
||||||
|
if (ssrSupport.value) {
|
||||||
|
ssrSupport.value = !isSupported.value;
|
||||||
|
matches.value = matchSsrWidth(toValue(query), ssrWidth!);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSupported.value)
|
||||||
|
return;
|
||||||
|
|
||||||
|
mediaQuery.value = window!.matchMedia(toValue(query));
|
||||||
|
matches.value = mediaQuery.value.matches;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reactive target: re-binds automatically when the query (and thus the
|
||||||
|
// MediaQueryList) changes, and auto-cleans on scope dispose. Passive since
|
||||||
|
// we never call preventDefault.
|
||||||
|
useEventListener(mediaQuery, 'change', handler, { passive: true });
|
||||||
|
|
||||||
|
return computed(() => matches.value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope, nextTick, shallowRef } from 'vue';
|
||||||
|
import { useMouse } from '.';
|
||||||
|
|
||||||
|
function dispatchMouseMove(target: EventTarget, coords: Partial<Record<'pageX' | 'pageY' | 'clientX' | 'clientY' | 'screenX' | 'screenY' | 'movementX' | 'movementY', number>>) {
|
||||||
|
const event = new MouseEvent('mousemove');
|
||||||
|
for (const [key, value] of Object.entries(coords))
|
||||||
|
Object.defineProperty(event, key, { value, configurable: true });
|
||||||
|
target.dispatchEvent(event);
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(useMouse, () => {
|
||||||
|
it('uses the initial value', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let mouse: ReturnType<typeof useMouse>;
|
||||||
|
scope.run(() => {
|
||||||
|
mouse = useMouse({ initialValue: { x: 5, y: 10 } });
|
||||||
|
});
|
||||||
|
expect(mouse!.x.value).toBe(5);
|
||||||
|
expect(mouse!.y.value).toBe(10);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks mousemove with page coordinates', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let mouse: ReturnType<typeof useMouse>;
|
||||||
|
scope.run(() => {
|
||||||
|
mouse = useMouse();
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatchMouseMove(globalThis, { pageX: 100, pageY: 200 });
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(mouse!.x.value).toBe(100);
|
||||||
|
expect(mouse!.y.value).toBe(200);
|
||||||
|
expect(mouse!.sourceType.value).toBe('mouse');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports client coordinate type', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let mouse: ReturnType<typeof useMouse>;
|
||||||
|
scope.run(() => {
|
||||||
|
mouse = useMouse({ type: 'client' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const event = new MouseEvent('mousemove', { clientX: 7, clientY: 9 });
|
||||||
|
globalThis.dispatchEvent(event);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(mouse!.x.value).toBe(7);
|
||||||
|
expect(mouse!.y.value).toBe(9);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports screen coordinate type', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let mouse: ReturnType<typeof useMouse>;
|
||||||
|
scope.run(() => {
|
||||||
|
mouse = useMouse({ type: 'screen' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const event = new MouseEvent('mousemove', { screenX: 11, screenY: 22 });
|
||||||
|
globalThis.dispatchEvent(event);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(mouse!.x.value).toBe(11);
|
||||||
|
expect(mouse!.y.value).toBe(22);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports a custom extractor function', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let mouse: ReturnType<typeof useMouse>;
|
||||||
|
scope.run(() => {
|
||||||
|
mouse = useMouse({ type: e => [(e as MouseEvent).pageX * 2, (e as MouseEvent).pageY * 2] });
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatchMouseMove(globalThis, { pageX: 3, pageY: 4 });
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(mouse!.x.value).toBe(6);
|
||||||
|
expect(mouse!.y.value).toBe(8);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not update when the extractor returns null', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let mouse: ReturnType<typeof useMouse>;
|
||||||
|
// a custom extractor that opts out of updating
|
||||||
|
scope.run(() => {
|
||||||
|
mouse = useMouse({ type: () => null, initialValue: { x: 1, y: 2 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatchMouseMove(globalThis, { pageX: 99, pageY: 99 });
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(mouse!.x.value).toBe(1);
|
||||||
|
expect(mouse!.y.value).toBe(2);
|
||||||
|
expect(mouse!.sourceType.value).toBe(null);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks touch events', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let mouse: ReturnType<typeof useMouse>;
|
||||||
|
scope.run(() => {
|
||||||
|
mouse = useMouse();
|
||||||
|
});
|
||||||
|
|
||||||
|
const touch = { clientX: 0, clientY: 0, pageX: 50, pageY: 60 } as Touch;
|
||||||
|
const event = new Event('touchstart') as TouchEvent;
|
||||||
|
Object.defineProperty(event, 'touches', { value: [touch], configurable: true });
|
||||||
|
globalThis.dispatchEvent(event);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(mouse!.x.value).toBe(50);
|
||||||
|
expect(mouse!.y.value).toBe(60);
|
||||||
|
expect(mouse!.sourceType.value).toBe('touch');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores touch events when touch is disabled', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let mouse: ReturnType<typeof useMouse>;
|
||||||
|
scope.run(() => {
|
||||||
|
mouse = useMouse({ touch: false, initialValue: { x: 1, y: 1 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
const touch = { pageX: 50, pageY: 60 } as Touch;
|
||||||
|
const event = new Event('touchstart') as TouchEvent;
|
||||||
|
Object.defineProperty(event, 'touches', { value: [touch], configurable: true });
|
||||||
|
globalThis.dispatchEvent(event);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(mouse!.x.value).toBe(1);
|
||||||
|
expect(mouse!.y.value).toBe(1);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets on touchend when resetOnTouchEnds is set', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let mouse: ReturnType<typeof useMouse>;
|
||||||
|
scope.run(() => {
|
||||||
|
mouse = useMouse({ resetOnTouchEnds: true, initialValue: { x: 9, y: 9 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
const touch = { pageX: 50, pageY: 60 } as Touch;
|
||||||
|
const start = new Event('touchstart') as TouchEvent;
|
||||||
|
Object.defineProperty(start, 'touches', { value: [touch], configurable: true });
|
||||||
|
globalThis.dispatchEvent(start);
|
||||||
|
await nextTick();
|
||||||
|
expect(mouse!.x.value).toBe(50);
|
||||||
|
|
||||||
|
globalThis.dispatchEvent(new Event('touchend'));
|
||||||
|
await nextTick();
|
||||||
|
expect(mouse!.x.value).toBe(9);
|
||||||
|
expect(mouse!.y.value).toBe(9);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates page coordinates on scroll without pointer movement', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let mouse: ReturnType<typeof useMouse>;
|
||||||
|
scope.run(() => {
|
||||||
|
mouse = useMouse({ type: 'page' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// record a mouse position while scrollX/scrollY are 0
|
||||||
|
(globalThis as any).scrollX = 0;
|
||||||
|
(globalThis as any).scrollY = 0;
|
||||||
|
dispatchMouseMove(globalThis, { pageX: 100, pageY: 100 });
|
||||||
|
await nextTick();
|
||||||
|
expect(mouse!.x.value).toBe(100);
|
||||||
|
|
||||||
|
// scroll the page; page coordinates should shift by the scroll delta
|
||||||
|
(globalThis as any).scrollX = 30;
|
||||||
|
(globalThis as any).scrollY = 40;
|
||||||
|
globalThis.dispatchEvent(new Event('scroll'));
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(mouse!.x.value).toBe(130);
|
||||||
|
expect(mouse!.y.value).toBe(140);
|
||||||
|
|
||||||
|
(globalThis as any).scrollX = 0;
|
||||||
|
(globalThis as any).scrollY = 0;
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not register a scroll listener for non-page types', async () => {
|
||||||
|
const addSpy = vi.spyOn(globalThis, 'addEventListener');
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
useMouse({ type: 'client' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const scrollCalls = addSpy.mock.calls.filter(([name]) => name === 'scroll');
|
||||||
|
expect(scrollCalls).toHaveLength(0);
|
||||||
|
|
||||||
|
addSpy.mockRestore();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attaches passive listeners', () => {
|
||||||
|
const addSpy = vi.spyOn(globalThis, 'addEventListener');
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
useMouse();
|
||||||
|
});
|
||||||
|
|
||||||
|
const moveCall = addSpy.mock.calls.find(([name]) => name === 'mousemove');
|
||||||
|
expect(moveCall).toBeDefined();
|
||||||
|
expect((moveCall![2] as AddEventListenerOptions).passive).toBeTruthy();
|
||||||
|
|
||||||
|
addSpy.mockRestore();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listens on a custom element target', async () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const elRef = shallowRef(el);
|
||||||
|
const addSpy = vi.spyOn(el, 'addEventListener');
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let mouse: ReturnType<typeof useMouse>;
|
||||||
|
scope.run(() => {
|
||||||
|
mouse = useMouse({ target: elRef, type: 'client' });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(addSpy.mock.calls.some(([name]) => name === 'mousemove')).toBeTruthy();
|
||||||
|
|
||||||
|
const event = new MouseEvent('mousemove', { clientX: 5, clientY: 6 });
|
||||||
|
el.dispatchEvent(event);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(mouse!.x.value).toBe(5);
|
||||||
|
expect(mouse!.y.value).toBe(6);
|
||||||
|
|
||||||
|
addSpy.mockRestore();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when window is unavailable (SSR)', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let mouse: ReturnType<typeof useMouse>;
|
||||||
|
scope.run(() => {
|
||||||
|
mouse = useMouse({ window: undefined, target: undefined });
|
||||||
|
});
|
||||||
|
expect(mouse!.x.value).toBe(0);
|
||||||
|
expect(mouse!.y.value).toBe(0);
|
||||||
|
expect(mouse!.sourceType.value).toBe(null);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies an event filter (throttle drops intermediate moves)', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const filtered = vi.fn();
|
||||||
|
// simple leading-only filter: only the first invoke passes immediately
|
||||||
|
let used = false;
|
||||||
|
const onceFilter = (invoke: () => void) => {
|
||||||
|
if (!used) {
|
||||||
|
used = true;
|
||||||
|
invoke();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let mouse: ReturnType<typeof useMouse>;
|
||||||
|
scope.run(() => {
|
||||||
|
mouse = useMouse({ eventFilter: onceFilter });
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatchMouseMove(globalThis, { pageX: 10, pageY: 10 });
|
||||||
|
dispatchMouseMove(globalThis, { pageX: 20, pageY: 20 });
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
// only the first move was let through
|
||||||
|
expect(mouse!.x.value).toBe(10);
|
||||||
|
expect(mouse!.y.value).toBe(10);
|
||||||
|
|
||||||
|
filtered();
|
||||||
|
scope.stop();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { shallowRef } from 'vue';
|
||||||
|
import type { Ref } from 'vue';
|
||||||
|
import { isFunction } from '@robonen/stdlib';
|
||||||
|
import { defaultWindow } from '@/types';
|
||||||
|
import type { ConfigurableWindow } from '@/types';
|
||||||
|
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||||
|
import { unrefElement } from '@/composables/component/unrefElement';
|
||||||
|
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
||||||
|
import { bypassFilter, createFilterWrapper } from '@/utils/filters';
|
||||||
|
import type { ConfigurableEventFilter } from '@/utils/filters';
|
||||||
|
|
||||||
|
export type UseMouseCoordType = 'page' | 'client' | 'screen' | 'movement';
|
||||||
|
export type UseMouseSourceType = 'mouse' | 'touch' | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts an `[x, y]` pair from a mouse or touch point, or `null` to skip.
|
||||||
|
*/
|
||||||
|
export type UseMouseEventExtractor = (event: MouseEvent | Touch) => [x: number, y: number] | null | undefined;
|
||||||
|
|
||||||
|
export interface UseMouseOptions extends ConfigurableWindow, ConfigurableEventFilter {
|
||||||
|
/**
|
||||||
|
* Which coordinate pair to read from the event, or a custom extractor function
|
||||||
|
*
|
||||||
|
* @default 'page'
|
||||||
|
*/
|
||||||
|
type?: UseMouseCoordType | UseMouseEventExtractor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Target to attach the listeners to. Accepts a window, document, element ref,
|
||||||
|
* getter, or component instance.
|
||||||
|
*
|
||||||
|
* @default window
|
||||||
|
*/
|
||||||
|
target?: MaybeComputedElementRef | Window | Document;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listen to touch events
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
touch?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track window scroll so that `page` coordinates stay accurate while the page
|
||||||
|
* scrolls without the pointer moving. Only applies when `type === 'page'`.
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
scroll?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset coordinates to `initialValue` on `touchend`
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
resetOnTouchEnds?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initial coordinates
|
||||||
|
*
|
||||||
|
* @default { x: 0, y: 0 }
|
||||||
|
*/
|
||||||
|
initialValue?: { x: number; y: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseMouseReturn {
|
||||||
|
x: Ref<number>;
|
||||||
|
y: Ref<number>;
|
||||||
|
sourceType: Ref<UseMouseSourceType>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const builtinExtractors: Record<UseMouseCoordType, UseMouseEventExtractor> = {
|
||||||
|
page: e => [(e as MouseEvent).pageX, (e as MouseEvent).pageY],
|
||||||
|
client: e => [e.clientX, e.clientY],
|
||||||
|
screen: e => [e.screenX, e.screenY],
|
||||||
|
movement: e => ('movementX' in e ? [e.movementX, e.movementY] : null),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useMouse
|
||||||
|
* @category Browser
|
||||||
|
* @description Reactive mouse (and optionally touch) position with optional
|
||||||
|
* custom target, scroll tracking, custom extractors, and event filtering.
|
||||||
|
*
|
||||||
|
* @param {UseMouseOptions} [options={}] Options
|
||||||
|
* @returns {UseMouseReturn} Reactive `x`, `y`, and `sourceType`
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { x, y, sourceType } = useMouse();
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Track relative to an element, throttled
|
||||||
|
* const { x, y } = useMouse({ target: el, eventFilter: throttleFilter(50) });
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useMouse(options: UseMouseOptions = {}): UseMouseReturn {
|
||||||
|
const {
|
||||||
|
type = 'page',
|
||||||
|
touch = true,
|
||||||
|
scroll = true,
|
||||||
|
resetOnTouchEnds = false,
|
||||||
|
initialValue = { x: 0, y: 0 },
|
||||||
|
window = defaultWindow,
|
||||||
|
target = window,
|
||||||
|
eventFilter,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
let prevMouseEvent: MouseEvent | null = null;
|
||||||
|
let prevScrollX = 0;
|
||||||
|
let prevScrollY = 0;
|
||||||
|
|
||||||
|
const x = shallowRef(initialValue.x);
|
||||||
|
const y = shallowRef(initialValue.y);
|
||||||
|
const sourceType = shallowRef<UseMouseSourceType>(null);
|
||||||
|
|
||||||
|
const isExtractorFn = isFunction(type);
|
||||||
|
const extractor: UseMouseEventExtractor = isExtractorFn ? type : builtinExtractors[type];
|
||||||
|
|
||||||
|
const mouseHandler = (event: MouseEvent) => {
|
||||||
|
const result = extractor(event);
|
||||||
|
prevMouseEvent = event;
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
[x.value, y.value] = result;
|
||||||
|
sourceType.value = 'mouse';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window) {
|
||||||
|
prevScrollX = window.scrollX;
|
||||||
|
prevScrollY = window.scrollY;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const touchHandler = (event: TouchEvent) => {
|
||||||
|
if (event.touches.length) {
|
||||||
|
const result = extractor(event.touches[0]!);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
[x.value, y.value] = result;
|
||||||
|
sourceType.value = 'touch';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keep page coordinates correct when scrolling without moving the pointer.
|
||||||
|
const scrollHandler = () => {
|
||||||
|
if (!prevMouseEvent || !window)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const result = extractor(prevMouseEvent);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
x.value = result[0] + window.scrollX - prevScrollX;
|
||||||
|
y.value = result[1] + window.scrollY - prevScrollY;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
x.value = initialValue.x;
|
||||||
|
y.value = initialValue.y;
|
||||||
|
};
|
||||||
|
|
||||||
|
const filter = eventFilter ?? bypassFilter;
|
||||||
|
const mouseHandlerWrapper = createFilterWrapper(filter, mouseHandler);
|
||||||
|
const touchHandlerWrapper = createFilterWrapper(filter, touchHandler);
|
||||||
|
const scrollHandlerWrapper = createFilterWrapper(filter, scrollHandler);
|
||||||
|
|
||||||
|
const trackTouch = touch && !(isExtractorFn ? false : type === 'movement');
|
||||||
|
const trackScroll = scroll && !!window && (isExtractorFn ? true : type === 'page');
|
||||||
|
|
||||||
|
// A raw window/document/EventTarget is used directly (fast, non-reactive path
|
||||||
|
// in useEventListener). Refs/getters/element instances are resolved lazily via
|
||||||
|
// a getter so the listeners re-bind when the underlying element changes.
|
||||||
|
const listenTarget = isTarget(target)
|
||||||
|
? target
|
||||||
|
: (): EventTarget | null | undefined => unrefElement(target as MaybeComputedElementRef) as EventTarget | null | undefined;
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
const listenerOptions = { passive: true };
|
||||||
|
|
||||||
|
useEventListener(listenTarget, ['mousemove', 'dragover'], mouseHandlerWrapper as unknown as (e: Event) => void, listenerOptions);
|
||||||
|
|
||||||
|
if (trackTouch) {
|
||||||
|
useEventListener(listenTarget, ['touchstart', 'touchmove'], touchHandlerWrapper as unknown as (e: Event) => void, listenerOptions);
|
||||||
|
|
||||||
|
if (resetOnTouchEnds)
|
||||||
|
useEventListener(listenTarget, 'touchend', reset, listenerOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trackScroll)
|
||||||
|
useEventListener(window, 'scroll', scrollHandlerWrapper as (e: Event) => void, listenerOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { x, y, sourceType };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `true` for an object that is itself an event target (window/document/element)
|
||||||
|
* and should be attached to directly, rather than unwrapped from a ref/getter.
|
||||||
|
*/
|
||||||
|
function isTarget(value: unknown): value is EventTarget {
|
||||||
|
return typeof value === 'object' && value !== null && 'addEventListener' in value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope, nextTick, shallowRef } from 'vue';
|
||||||
|
import { useMousePressed } from '.';
|
||||||
|
|
||||||
|
function dispatch(target: EventTarget, type: string): Event {
|
||||||
|
const event = new Event(type);
|
||||||
|
target.dispatchEvent(event);
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(useMousePressed, () => {
|
||||||
|
it('starts not pressed by default', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let res: ReturnType<typeof useMousePressed>;
|
||||||
|
scope.run(() => {
|
||||||
|
res = useMousePressed();
|
||||||
|
});
|
||||||
|
expect(res!.pressed.value).toBeFalsy();
|
||||||
|
expect(res!.sourceType.value).toBe(null);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors the initial value', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let res: ReturnType<typeof useMousePressed>;
|
||||||
|
scope.run(() => {
|
||||||
|
res = useMousePressed({ initialValue: true });
|
||||||
|
});
|
||||||
|
expect(res!.pressed.value).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets pressed and mouse sourceType on mousedown, clears on mouseup', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let res: ReturnType<typeof useMousePressed>;
|
||||||
|
scope.run(() => {
|
||||||
|
res = useMousePressed();
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch(globalThis, 'mousedown');
|
||||||
|
await nextTick();
|
||||||
|
expect(res!.pressed.value).toBeTruthy();
|
||||||
|
expect(res!.sourceType.value).toBe('mouse');
|
||||||
|
|
||||||
|
dispatch(globalThis, 'mouseup');
|
||||||
|
await nextTick();
|
||||||
|
expect(res!.pressed.value).toBeFalsy();
|
||||||
|
expect(res!.sourceType.value).toBe(null);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears pressed on mouseleave', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let res: ReturnType<typeof useMousePressed>;
|
||||||
|
scope.run(() => {
|
||||||
|
res = useMousePressed();
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch(globalThis, 'mousedown');
|
||||||
|
await nextTick();
|
||||||
|
expect(res!.pressed.value).toBeTruthy();
|
||||||
|
|
||||||
|
dispatch(globalThis, 'mouseleave');
|
||||||
|
await nextTick();
|
||||||
|
expect(res!.pressed.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks touch presses with touch sourceType', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let res: ReturnType<typeof useMousePressed>;
|
||||||
|
scope.run(() => {
|
||||||
|
res = useMousePressed();
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch(globalThis, 'touchstart');
|
||||||
|
await nextTick();
|
||||||
|
expect(res!.pressed.value).toBeTruthy();
|
||||||
|
expect(res!.sourceType.value).toBe('touch');
|
||||||
|
|
||||||
|
dispatch(globalThis, 'touchend');
|
||||||
|
await nextTick();
|
||||||
|
expect(res!.pressed.value).toBeFalsy();
|
||||||
|
expect(res!.sourceType.value).toBe(null);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears pressed on touchcancel', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let res: ReturnType<typeof useMousePressed>;
|
||||||
|
scope.run(() => {
|
||||||
|
res = useMousePressed();
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch(globalThis, 'touchstart');
|
||||||
|
await nextTick();
|
||||||
|
expect(res!.pressed.value).toBeTruthy();
|
||||||
|
|
||||||
|
dispatch(globalThis, 'touchcancel');
|
||||||
|
await nextTick();
|
||||||
|
expect(res!.pressed.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not register touch listeners when touch is disabled', async () => {
|
||||||
|
const addSpy = vi.spyOn(globalThis, 'addEventListener');
|
||||||
|
const scope = effectScope();
|
||||||
|
let res: ReturnType<typeof useMousePressed>;
|
||||||
|
scope.run(() => {
|
||||||
|
res = useMousePressed({ touch: false });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const touchCalls = addSpy.mock.calls.filter(([name]) => String(name).startsWith('touch'));
|
||||||
|
expect(touchCalls).toHaveLength(0);
|
||||||
|
|
||||||
|
dispatch(globalThis, 'touchstart');
|
||||||
|
await nextTick();
|
||||||
|
expect(res!.pressed.value).toBeFalsy();
|
||||||
|
|
||||||
|
addSpy.mockRestore();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks drag presses when drag is enabled', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let res: ReturnType<typeof useMousePressed>;
|
||||||
|
scope.run(() => {
|
||||||
|
res = useMousePressed();
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch(globalThis, 'dragstart');
|
||||||
|
await nextTick();
|
||||||
|
expect(res!.pressed.value).toBeTruthy();
|
||||||
|
expect(res!.sourceType.value).toBe('mouse');
|
||||||
|
|
||||||
|
dispatch(globalThis, 'dragend');
|
||||||
|
await nextTick();
|
||||||
|
expect(res!.pressed.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears pressed on drop', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let res: ReturnType<typeof useMousePressed>;
|
||||||
|
scope.run(() => {
|
||||||
|
res = useMousePressed();
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch(globalThis, 'dragstart');
|
||||||
|
await nextTick();
|
||||||
|
expect(res!.pressed.value).toBeTruthy();
|
||||||
|
|
||||||
|
dispatch(globalThis, 'drop');
|
||||||
|
await nextTick();
|
||||||
|
expect(res!.pressed.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not register drag listeners when drag is disabled', async () => {
|
||||||
|
const addSpy = vi.spyOn(globalThis, 'addEventListener');
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
useMousePressed({ drag: false });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const dragCalls = addSpy.mock.calls.filter(([name]) => String(name).startsWith('drag') || name === 'drop');
|
||||||
|
expect(dragCalls).toHaveLength(0);
|
||||||
|
|
||||||
|
addSpy.mockRestore();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invokes onPressed and onReleased callbacks', async () => {
|
||||||
|
const onPressed = vi.fn();
|
||||||
|
const onReleased = vi.fn();
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
useMousePressed({ onPressed, onReleased });
|
||||||
|
});
|
||||||
|
|
||||||
|
const down = dispatch(globalThis, 'mousedown');
|
||||||
|
await nextTick();
|
||||||
|
expect(onPressed).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onPressed).toHaveBeenCalledWith(down);
|
||||||
|
|
||||||
|
const up = dispatch(globalThis, 'mouseup');
|
||||||
|
await nextTick();
|
||||||
|
expect(onReleased).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onReleased).toHaveBeenCalledWith(up);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attaches passive listeners and respects the capture option', async () => {
|
||||||
|
const addSpy = vi.spyOn(globalThis, 'addEventListener');
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
useMousePressed({ capture: true });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const downCall = addSpy.mock.calls.find(([name]) => name === 'mousedown');
|
||||||
|
expect(downCall).toBeDefined();
|
||||||
|
const opts = downCall![2] as AddEventListenerOptions;
|
||||||
|
expect(opts.passive).toBeTruthy();
|
||||||
|
expect(opts.capture).toBeTruthy();
|
||||||
|
|
||||||
|
addSpy.mockRestore();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listens for press on a custom element target', async () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const elRef = shallowRef(el);
|
||||||
|
const addSpy = vi.spyOn(el, 'addEventListener');
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let res: ReturnType<typeof useMousePressed>;
|
||||||
|
scope.run(() => {
|
||||||
|
res = useMousePressed({ target: elRef });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(addSpy.mock.calls.some(([name]) => name === 'mousedown')).toBeTruthy();
|
||||||
|
|
||||||
|
dispatch(el, 'mousedown');
|
||||||
|
await nextTick();
|
||||||
|
expect(res!.pressed.value).toBeTruthy();
|
||||||
|
expect(res!.sourceType.value).toBe('mouse');
|
||||||
|
|
||||||
|
// release still listens on window
|
||||||
|
dispatch(globalThis, 'mouseup');
|
||||||
|
await nextTick();
|
||||||
|
expect(res!.pressed.value).toBeFalsy();
|
||||||
|
|
||||||
|
addSpy.mockRestore();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when window is unavailable (SSR)', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let res: ReturnType<typeof useMousePressed>;
|
||||||
|
scope.run(() => {
|
||||||
|
res = useMousePressed({ window: undefined, initialValue: true });
|
||||||
|
});
|
||||||
|
// returns refs without attaching listeners
|
||||||
|
expect(res!.pressed.value).toBeTruthy();
|
||||||
|
expect(res!.sourceType.value).toBe(null);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { computed, shallowRef } from 'vue';
|
||||||
|
import type { ComputedRef, ShallowRef } from 'vue';
|
||||||
|
import { defaultWindow } from '@/types';
|
||||||
|
import type { ConfigurableWindow } from '@/types';
|
||||||
|
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||||
|
import { unrefElement } from '@/composables/component/unrefElement';
|
||||||
|
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
||||||
|
import type { UseMouseSourceType } from '@/composables/browser/useMouse';
|
||||||
|
|
||||||
|
export type UseMousePressedEvent = MouseEvent | TouchEvent | DragEvent;
|
||||||
|
|
||||||
|
export interface UseMousePressedOptions extends ConfigurableWindow {
|
||||||
|
/**
|
||||||
|
* Listen to `touchstart`, `touchend`, and `touchcancel` events
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
touch?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listen to `dragstart`, `drop`, and `dragend` events
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
drag?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add event listeners with the `capture` option set to `true`
|
||||||
|
* (see [MDN](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#capture))
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
capture?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initial pressed state
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
initialValue?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Element target to capture the press on. Accepts an element ref, getter,
|
||||||
|
* or component instance. Defaults to `window` when omitted.
|
||||||
|
*/
|
||||||
|
target?: MaybeComputedElementRef;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback invoked when a press starts
|
||||||
|
*/
|
||||||
|
onPressed?: (event: UseMousePressedEvent) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback invoked when a press is released
|
||||||
|
*/
|
||||||
|
onReleased?: (event: UseMousePressedEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseMousePressedReturn {
|
||||||
|
pressed: ShallowRef<boolean>;
|
||||||
|
sourceType: ShallowRef<UseMouseSourceType>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useMousePressed
|
||||||
|
* @category Browser
|
||||||
|
* @description Reactive mouse/touch/drag pressed state on a target, with the
|
||||||
|
* input source type and optional press/release callbacks.
|
||||||
|
*
|
||||||
|
* @param {UseMousePressedOptions} [options={}] Options
|
||||||
|
* @returns {UseMousePressedReturn} Reactive `pressed` and `sourceType`
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { pressed, sourceType } = useMousePressed();
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Track presses only on a specific element, ignore touch
|
||||||
|
* const { pressed } = useMousePressed({ target: el, touch: false });
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useMousePressed(options: UseMousePressedOptions = {}): UseMousePressedReturn {
|
||||||
|
const {
|
||||||
|
touch = true,
|
||||||
|
drag = true,
|
||||||
|
capture = false,
|
||||||
|
initialValue = false,
|
||||||
|
window = defaultWindow,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const pressed = shallowRef(initialValue);
|
||||||
|
const sourceType = shallowRef<UseMouseSourceType>(null);
|
||||||
|
|
||||||
|
if (!window)
|
||||||
|
return { pressed, sourceType };
|
||||||
|
|
||||||
|
const onPressed = (srcType: UseMouseSourceType) => (event: UseMousePressedEvent): void => {
|
||||||
|
pressed.value = true;
|
||||||
|
sourceType.value = srcType;
|
||||||
|
options.onPressed?.(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onReleased = (event: UseMousePressedEvent): void => {
|
||||||
|
pressed.value = false;
|
||||||
|
sourceType.value = null;
|
||||||
|
options.onReleased?.(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const target: ComputedRef<EventTarget> = computed(() => unrefElement(options.target) ?? window);
|
||||||
|
|
||||||
|
const listenerOptions = { passive: true, capture };
|
||||||
|
|
||||||
|
useEventListener(target, 'mousedown', onPressed('mouse') as (e: Event) => void, listenerOptions);
|
||||||
|
useEventListener(window, 'mouseleave', onReleased as (e: Event) => void, listenerOptions);
|
||||||
|
useEventListener(window, 'mouseup', onReleased as (e: Event) => void, listenerOptions);
|
||||||
|
|
||||||
|
if (drag) {
|
||||||
|
useEventListener(target, 'dragstart', onPressed('mouse') as (e: Event) => void, listenerOptions);
|
||||||
|
useEventListener(window, 'drop', onReleased as (e: Event) => void, listenerOptions);
|
||||||
|
useEventListener(window, 'dragend', onReleased as (e: Event) => void, listenerOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (touch) {
|
||||||
|
useEventListener(target, 'touchstart', onPressed('touch') as (e: Event) => void, listenerOptions);
|
||||||
|
useEventListener(window, 'touchend', onReleased as (e: Event) => void, listenerOptions);
|
||||||
|
useEventListener(window, 'touchcancel', onReleased as (e: Event) => void, listenerOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pressed, sourceType };
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope, nextTick, ref } from 'vue';
|
||||||
|
import { useMutationObserver } from '.';
|
||||||
|
|
||||||
|
let instances: Array<{ cb: MutationCallback; observe: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn>; takeRecords: ReturnType<typeof vi.fn> }> = [];
|
||||||
|
|
||||||
|
class StubMutationObserver {
|
||||||
|
observe = vi.fn();
|
||||||
|
disconnect = vi.fn();
|
||||||
|
takeRecords = vi.fn(() => []);
|
||||||
|
cb: MutationCallback;
|
||||||
|
constructor(cb: MutationCallback) {
|
||||||
|
this.cb = cb;
|
||||||
|
instances.push(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(useMutationObserver, () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
instances = [];
|
||||||
|
vi.stubGlobal('MutationObserver', StubMutationObserver);
|
||||||
|
});
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('observes the target with the given options', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useMutationObserver(ref(el), vi.fn(), { attributes: true }));
|
||||||
|
|
||||||
|
expect(instances).toHaveLength(1);
|
||||||
|
expect(instances[0]!.observe).toHaveBeenCalledWith(el, { attributes: true });
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not leak immediate/window into observer options', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useMutationObserver(ref(el), vi.fn(), { childList: true, immediate: true }));
|
||||||
|
|
||||||
|
expect(instances[0]!.observe).toHaveBeenCalledWith(el, { childList: true });
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disconnects on stop', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
let stop: () => void;
|
||||||
|
scope.run(() => {
|
||||||
|
stop = useMutationObserver(ref(el), vi.fn()).stop;
|
||||||
|
});
|
||||||
|
|
||||||
|
stop!();
|
||||||
|
expect(instances[0]!.disconnect).toHaveBeenCalled();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards records to the callback', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const callback = vi.fn();
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useMutationObserver(ref(el), callback));
|
||||||
|
|
||||||
|
const records = [{ type: 'attributes' } as MutationRecord];
|
||||||
|
instances[0]!.cb(records, instances[0] as unknown as MutationObserver);
|
||||||
|
expect(callback).toHaveBeenCalledWith(records, expect.anything());
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('observes an array of targets with a single observer', () => {
|
||||||
|
const a = document.createElement('div');
|
||||||
|
const b = document.createElement('span');
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useMutationObserver([ref(a), b], vi.fn(), { childList: true }));
|
||||||
|
|
||||||
|
expect(instances).toHaveLength(1);
|
||||||
|
expect(instances[0]!.observe).toHaveBeenCalledTimes(2);
|
||||||
|
expect(instances[0]!.observe).toHaveBeenCalledWith(a, { childList: true });
|
||||||
|
expect(instances[0]!.observe).toHaveBeenCalledWith(b, { childList: true });
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a getter returning an array of targets', () => {
|
||||||
|
const a = document.createElement('div');
|
||||||
|
const b = document.createElement('span');
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useMutationObserver(() => [a, b], vi.fn(), { childList: true }));
|
||||||
|
|
||||||
|
expect(instances).toHaveLength(1);
|
||||||
|
expect(instances[0]!.observe).toHaveBeenCalledTimes(2);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates repeated targets', () => {
|
||||||
|
const a = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useMutationObserver([a, a, ref(a)], vi.fn(), { childList: true }));
|
||||||
|
|
||||||
|
expect(instances[0]!.observe).toHaveBeenCalledTimes(1);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips nullish targets', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useMutationObserver([ref(null), ref(undefined)], vi.fn(), { childList: true }));
|
||||||
|
|
||||||
|
expect(instances).toHaveLength(0);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-observes when a reactive target changes', async () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const target = ref<HTMLElement | null>(null);
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useMutationObserver(target, vi.fn(), { childList: true }));
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
expect(instances).toHaveLength(0);
|
||||||
|
|
||||||
|
target.value = el;
|
||||||
|
await nextTick();
|
||||||
|
expect(instances).toHaveLength(1);
|
||||||
|
expect(instances[0]!.observe).toHaveBeenCalledWith(el, { childList: true });
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not observe when immediate is false, then resumes', async () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
let api: ReturnType<typeof useMutationObserver>;
|
||||||
|
scope.run(() => {
|
||||||
|
api = useMutationObserver(ref(el), vi.fn(), { attributes: true, immediate: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
expect(instances).toHaveLength(0);
|
||||||
|
expect(api!.isActive.value).toBeFalsy();
|
||||||
|
|
||||||
|
api!.resume();
|
||||||
|
await nextTick();
|
||||||
|
expect(instances).toHaveLength(1);
|
||||||
|
expect(api!.isActive.value).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pause disconnects and flips isActive, resume re-observes', async () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
let api: ReturnType<typeof useMutationObserver>;
|
||||||
|
scope.run(() => {
|
||||||
|
api = useMutationObserver(ref(el), vi.fn(), { attributes: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(instances).toHaveLength(1);
|
||||||
|
|
||||||
|
api!.pause();
|
||||||
|
expect(instances[0]!.disconnect).toHaveBeenCalled();
|
||||||
|
expect(api!.isActive.value).toBeFalsy();
|
||||||
|
|
||||||
|
api!.resume();
|
||||||
|
await nextTick();
|
||||||
|
expect(instances).toHaveLength(2);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takeRecords proxies to the active observer and returns undefined when inactive', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
let api: ReturnType<typeof useMutationObserver>;
|
||||||
|
scope.run(() => {
|
||||||
|
api = useMutationObserver(ref(el), vi.fn());
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(api!.takeRecords()).toEqual([]);
|
||||||
|
expect(instances[0]!.takeRecords).toHaveBeenCalled();
|
||||||
|
|
||||||
|
api!.stop();
|
||||||
|
expect(api!.takeRecords()).toBeUndefined();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports isSupported false when MutationObserver is missing', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let api: ReturnType<typeof useMutationObserver>;
|
||||||
|
const el = document.createElement('div');
|
||||||
|
scope.run(() => {
|
||||||
|
api = useMutationObserver(ref(el), vi.fn(), { window: { foo: 1 } as unknown as Window & typeof globalThis });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(api!.isSupported.value).toBeFalsy();
|
||||||
|
expect(instances).toHaveLength(0);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { computed, readonly, ref, toValue, watch } from 'vue';
|
||||||
|
import type { MaybeRefOrGetter, Ref } from 'vue';
|
||||||
|
import { toArray } from '@robonen/stdlib';
|
||||||
|
import type { ConfigurableWindow } from '@/types';
|
||||||
|
import { defaultWindow } from '@/types';
|
||||||
|
import type { MaybeComputedElementRef, MaybeElement } from '@/composables/component/unrefElement';
|
||||||
|
import { unrefElement } from '@/composables/component/unrefElement';
|
||||||
|
import { useSupported } from '@/composables/browser/useSupported';
|
||||||
|
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||||
|
|
||||||
|
export interface UseMutationObserverOptions extends MutationObserverInit, ConfigurableWindow {
|
||||||
|
/**
|
||||||
|
* Start observing immediately once a target is available
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
immediate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseMutationObserverReturn {
|
||||||
|
isSupported: Readonly<Ref<boolean>>;
|
||||||
|
/**
|
||||||
|
* Whether the observer is currently active (not paused or stopped)
|
||||||
|
*/
|
||||||
|
isActive: Readonly<Ref<boolean>>;
|
||||||
|
/**
|
||||||
|
* Temporarily disconnect the observer without tearing down the watcher.
|
||||||
|
* Re-observe with `resume`.
|
||||||
|
*/
|
||||||
|
pause: () => void;
|
||||||
|
/**
|
||||||
|
* Re-attach the observer to the current target(s) after a `pause`.
|
||||||
|
*/
|
||||||
|
resume: () => void;
|
||||||
|
/**
|
||||||
|
* Permanently stop observing and dispose the watcher.
|
||||||
|
*/
|
||||||
|
stop: () => void;
|
||||||
|
/**
|
||||||
|
* Synchronously take and clear the observer's record queue
|
||||||
|
*/
|
||||||
|
takeRecords: () => MutationRecord[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useMutationObserver
|
||||||
|
* @category Browser
|
||||||
|
* @description Watch for changes to the DOM tree via `MutationObserver`.
|
||||||
|
* Accepts a single target, an array of targets, or a getter returning either.
|
||||||
|
*
|
||||||
|
* @param {MaybeComputedElementRef | MaybeComputedElementRef[] | MaybeRefOrGetter<MaybeElement[]>} target Element(s) to observe
|
||||||
|
* @param {MutationCallback} callback Invoked with the mutation records
|
||||||
|
* @param {UseMutationObserverOptions} [options={}] Observer options (childList, attributes, …)
|
||||||
|
* @returns {UseMutationObserverReturn} `isSupported`, `isActive`, `pause`, `resume`, `stop`, and `takeRecords`
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* useMutationObserver(el, (records) => {
|
||||||
|
* console.log(records);
|
||||||
|
* }, { attributes: true });
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { pause, resume } = useMutationObserver([elA, elB], onMutate, { childList: true });
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useMutationObserver(
|
||||||
|
target: MaybeComputedElementRef | MaybeComputedElementRef[] | MaybeRefOrGetter<MaybeElement[]>,
|
||||||
|
callback: MutationCallback,
|
||||||
|
options: UseMutationObserverOptions = {},
|
||||||
|
): UseMutationObserverReturn {
|
||||||
|
const { window = defaultWindow, immediate = true, ...observerOptions } = options;
|
||||||
|
|
||||||
|
const isSupported = useSupported(() => window && 'MutationObserver' in window);
|
||||||
|
|
||||||
|
let observer: MutationObserver | undefined;
|
||||||
|
|
||||||
|
const isActive = ref(immediate);
|
||||||
|
|
||||||
|
const targets = computed(() => {
|
||||||
|
const value = toArray(toValue(target));
|
||||||
|
const set = new Set<Element>();
|
||||||
|
|
||||||
|
for (const item of value) {
|
||||||
|
const el = unrefElement(item as MaybeComputedElementRef);
|
||||||
|
if (el)
|
||||||
|
set.add(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
return set;
|
||||||
|
});
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (observer) {
|
||||||
|
observer.disconnect();
|
||||||
|
observer = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const takeRecords = () => observer?.takeRecords();
|
||||||
|
|
||||||
|
const stopWatch = watch(
|
||||||
|
() => [targets.value, isActive.value] as const,
|
||||||
|
([els, active]) => {
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
if (!active || !isSupported.value || !window || !els.size)
|
||||||
|
return;
|
||||||
|
|
||||||
|
observer = new MutationObserver(callback);
|
||||||
|
for (const el of els)
|
||||||
|
observer.observe(el, observerOptions);
|
||||||
|
},
|
||||||
|
{ immediate: true, flush: 'post' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const resume = () => {
|
||||||
|
isActive.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pause = () => {
|
||||||
|
cleanup();
|
||||||
|
isActive.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
cleanup();
|
||||||
|
stopWatch();
|
||||||
|
};
|
||||||
|
|
||||||
|
tryOnScopeDispose(stop);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSupported,
|
||||||
|
isActive: readonly(isActive),
|
||||||
|
pause,
|
||||||
|
resume,
|
||||||
|
stop,
|
||||||
|
takeRecords,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope, nextTick } from 'vue';
|
||||||
|
import { useNetwork } from '.';
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
describe(useNetwork, () => {
|
||||||
|
it('exposes the full reactive network state shape', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let state: ReturnType<typeof useNetwork>;
|
||||||
|
scope.run(() => {
|
||||||
|
state = useNetwork();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state!.isOnline.value).toBe(navigator.onLine);
|
||||||
|
expect(state!.type.value).toBe('unknown');
|
||||||
|
// while online on mount, offlineAt stays undefined and onlineAt is stamped
|
||||||
|
expect(state!.offlineAt.value).toBeUndefined();
|
||||||
|
expect(typeof state!.onlineAt.value).toBe('number');
|
||||||
|
expect(state!.downlink.value).toBeUndefined();
|
||||||
|
expect(state!.downlinkMax.value).toBeUndefined();
|
||||||
|
expect(state!.rtt.value).toBeUndefined();
|
||||||
|
expect(state!.effectiveType.value).toBeUndefined();
|
||||||
|
expect(state!.saveData.value).toBeUndefined();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reflects the initial navigator.onLine from a supplied window', () => {
|
||||||
|
const fakeWindow = {
|
||||||
|
navigator: { onLine: false },
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
} as unknown as Window;
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let state: ReturnType<typeof useNetwork>;
|
||||||
|
scope.run(() => {
|
||||||
|
state = useNetwork({ window: fakeWindow });
|
||||||
|
});
|
||||||
|
expect(state!.isOnline.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records offlineAt and onlineAt timestamps on transitions', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let state: ReturnType<typeof useNetwork>;
|
||||||
|
scope.run(() => {
|
||||||
|
state = useNetwork();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state!.offlineAt.value).toBeUndefined();
|
||||||
|
|
||||||
|
globalThis.dispatchEvent(new Event('offline'));
|
||||||
|
await nextTick();
|
||||||
|
expect(state!.isOnline.value).toBeFalsy();
|
||||||
|
expect(typeof state!.offlineAt.value).toBe('number');
|
||||||
|
|
||||||
|
globalThis.dispatchEvent(new Event('online'));
|
||||||
|
await nextTick();
|
||||||
|
expect(state!.isOnline.value).toBeTruthy();
|
||||||
|
expect(typeof state!.onlineAt.value).toBe('number');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays inert and reports online when window is undefined (SSR)', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let state: ReturnType<typeof useNetwork>;
|
||||||
|
scope.run(() => {
|
||||||
|
state = useNetwork({ window: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state!.isOnline.value).toBeTruthy();
|
||||||
|
expect(state!.isSupported.value).toBeFalsy();
|
||||||
|
expect(state!.type.value).toBe('unknown');
|
||||||
|
expect(state!.downlink.value).toBeUndefined();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isSupported is false when Network Information API is unavailable', () => {
|
||||||
|
const fakeWindow = {
|
||||||
|
navigator: { onLine: true },
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
} as unknown as Window;
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let state: ReturnType<typeof useNetwork>;
|
||||||
|
scope.run(() => {
|
||||||
|
state = useNetwork({ window: fakeWindow });
|
||||||
|
});
|
||||||
|
expect(state!.isSupported.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads connection info and listens for change events when supported', () => {
|
||||||
|
const listeners: Record<string, Array<(e: Event) => void>> = {};
|
||||||
|
const connection = {
|
||||||
|
downlink: 10,
|
||||||
|
downlinkMax: 100,
|
||||||
|
effectiveType: '4g',
|
||||||
|
rtt: 50,
|
||||||
|
saveData: true,
|
||||||
|
type: 'wifi',
|
||||||
|
addEventListener: (event: string, cb: (e: Event) => void) => {
|
||||||
|
(listeners[event] ??= []).push(cb);
|
||||||
|
},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const fakeWindow = {
|
||||||
|
navigator: { onLine: true, connection },
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
} as unknown as Window;
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let state: ReturnType<typeof useNetwork>;
|
||||||
|
scope.run(() => {
|
||||||
|
state = useNetwork({ window: fakeWindow });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state!.isSupported.value).toBeTruthy();
|
||||||
|
expect(state!.downlink.value).toBe(10);
|
||||||
|
expect(state!.downlinkMax.value).toBe(100);
|
||||||
|
expect(state!.effectiveType.value).toBe('4g');
|
||||||
|
expect(state!.rtt.value).toBe(50);
|
||||||
|
expect(state!.saveData.value).toBeTruthy();
|
||||||
|
expect(state!.type.value).toBe('wifi');
|
||||||
|
|
||||||
|
// a registered change listener should re-read connection state
|
||||||
|
connection.downlink = 5;
|
||||||
|
connection.effectiveType = '3g';
|
||||||
|
listeners.change?.forEach(cb => cb(new Event('change')));
|
||||||
|
expect(state!.downlink.value).toBe(5);
|
||||||
|
expect(state!.effectiveType.value).toBe('3g');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to "unknown" type when connection.type is missing', () => {
|
||||||
|
const connection = {
|
||||||
|
downlink: 8,
|
||||||
|
effectiveType: '4g',
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const fakeWindow = {
|
||||||
|
navigator: { onLine: true, connection },
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
} as unknown as Window;
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let state: ReturnType<typeof useNetwork>;
|
||||||
|
scope.run(() => {
|
||||||
|
state = useNetwork({ window: fakeWindow });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state!.isSupported.value).toBeTruthy();
|
||||||
|
expect(state!.type.value).toBe('unknown');
|
||||||
|
expect(state!.downlink.value).toBe(8);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { shallowReadonly, shallowRef } from 'vue';
|
||||||
|
import type { ShallowRef } from 'vue';
|
||||||
|
import { timestamp } from '@robonen/stdlib';
|
||||||
|
import { defaultWindow } from '@/types';
|
||||||
|
import type { ConfigurableWindow } from '@/types';
|
||||||
|
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||||
|
import { useSupported } from '@/composables/browser/useSupported';
|
||||||
|
|
||||||
|
export type NetworkType
|
||||||
|
= | 'bluetooth'
|
||||||
|
| 'cellular'
|
||||||
|
| 'ethernet'
|
||||||
|
| 'none'
|
||||||
|
| 'wifi'
|
||||||
|
| 'wimax'
|
||||||
|
| 'other'
|
||||||
|
| 'unknown';
|
||||||
|
|
||||||
|
export type NetworkEffectiveType = 'slow-2g' | '2g' | '3g' | '4g' | undefined;
|
||||||
|
|
||||||
|
export interface UseNetworkOptions extends ConfigurableWindow {}
|
||||||
|
|
||||||
|
export interface UseNetworkReturn {
|
||||||
|
/**
|
||||||
|
* Whether the Network Information API (`navigator.connection`) is supported.
|
||||||
|
*/
|
||||||
|
isSupported: Readonly<ShallowRef<boolean>>;
|
||||||
|
/**
|
||||||
|
* Whether the browser is currently online (`navigator.onLine`).
|
||||||
|
*/
|
||||||
|
isOnline: Readonly<ShallowRef<boolean>>;
|
||||||
|
/**
|
||||||
|
* The timestamp of the last time the browser went offline, in ms.
|
||||||
|
*/
|
||||||
|
offlineAt: Readonly<ShallowRef<number | undefined>>;
|
||||||
|
/**
|
||||||
|
* The timestamp of the last time the browser came back online, in ms.
|
||||||
|
*/
|
||||||
|
onlineAt: Readonly<ShallowRef<number | undefined>>;
|
||||||
|
/**
|
||||||
|
* The estimated effective bandwidth in megabits per second.
|
||||||
|
*/
|
||||||
|
downlink: Readonly<ShallowRef<number | undefined>>;
|
||||||
|
/**
|
||||||
|
* The maximum downlink speed of the underlying connection technology, in Mbps.
|
||||||
|
*/
|
||||||
|
downlinkMax: Readonly<ShallowRef<number | undefined>>;
|
||||||
|
/**
|
||||||
|
* The effective type of the connection (`slow-2g`, `2g`, `3g`, or `4g`).
|
||||||
|
*/
|
||||||
|
effectiveType: Readonly<ShallowRef<NetworkEffectiveType>>;
|
||||||
|
/**
|
||||||
|
* The estimated effective round-trip time of the current connection, in ms.
|
||||||
|
*/
|
||||||
|
rtt: Readonly<ShallowRef<number | undefined>>;
|
||||||
|
/**
|
||||||
|
* Whether the user has requested a reduced data usage mode.
|
||||||
|
*/
|
||||||
|
saveData: Readonly<ShallowRef<boolean | undefined>>;
|
||||||
|
/**
|
||||||
|
* The type of connection a device is using to communicate with the network.
|
||||||
|
*/
|
||||||
|
type: Readonly<ShallowRef<NetworkType>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NetworkInformation extends EventTarget {
|
||||||
|
readonly downlink?: number;
|
||||||
|
readonly downlinkMax?: number;
|
||||||
|
readonly effectiveType?: NetworkEffectiveType;
|
||||||
|
readonly rtt?: number;
|
||||||
|
readonly saveData?: boolean;
|
||||||
|
readonly type?: NetworkType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useNetwork
|
||||||
|
* @category Browser
|
||||||
|
* @description Reactive Network Information API state plus online/offline status.
|
||||||
|
*
|
||||||
|
* @param {UseNetworkOptions} [options={}] Options
|
||||||
|
* @returns {UseNetworkReturn} Reactive online status, transition timestamps, and connection info
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { isOnline, offlineAt, downlink, effectiveType, saveData, type } = useNetwork();
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useNetwork(options: UseNetworkOptions = {}): UseNetworkReturn {
|
||||||
|
const { window = defaultWindow } = options;
|
||||||
|
const navigator = window?.navigator;
|
||||||
|
|
||||||
|
const isSupported = useSupported(() => !!navigator && 'connection' in navigator);
|
||||||
|
|
||||||
|
const isOnline = shallowRef(navigator?.onLine ?? true);
|
||||||
|
const saveData = shallowRef<boolean | undefined>(undefined);
|
||||||
|
const offlineAt = shallowRef<number | undefined>(undefined);
|
||||||
|
const onlineAt = shallowRef<number | undefined>(undefined);
|
||||||
|
const downlink = shallowRef<number | undefined>(undefined);
|
||||||
|
const downlinkMax = shallowRef<number | undefined>(undefined);
|
||||||
|
const rtt = shallowRef<number | undefined>(undefined);
|
||||||
|
const effectiveType = shallowRef<NetworkEffectiveType>(undefined);
|
||||||
|
const type = shallowRef<NetworkType>('unknown');
|
||||||
|
|
||||||
|
const connection = navigator && 'connection' in navigator
|
||||||
|
? (navigator as Navigator & { connection?: NetworkInformation }).connection
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
function updateNetworkInformation(): void {
|
||||||
|
if (!navigator)
|
||||||
|
return;
|
||||||
|
|
||||||
|
isOnline.value = navigator.onLine;
|
||||||
|
offlineAt.value = isOnline.value ? offlineAt.value : timestamp();
|
||||||
|
onlineAt.value = isOnline.value ? timestamp() : onlineAt.value;
|
||||||
|
|
||||||
|
if (connection) {
|
||||||
|
downlink.value = connection.downlink;
|
||||||
|
downlinkMax.value = connection.downlinkMax;
|
||||||
|
effectiveType.value = connection.effectiveType;
|
||||||
|
rtt.value = connection.rtt;
|
||||||
|
saveData.value = connection.saveData;
|
||||||
|
type.value = connection.type ?? 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listenerOptions = { passive: true } as const;
|
||||||
|
|
||||||
|
if (window) {
|
||||||
|
useEventListener(window, 'offline', () => {
|
||||||
|
isOnline.value = false;
|
||||||
|
offlineAt.value = timestamp();
|
||||||
|
}, listenerOptions);
|
||||||
|
|
||||||
|
useEventListener(window, 'online', () => {
|
||||||
|
isOnline.value = true;
|
||||||
|
onlineAt.value = timestamp();
|
||||||
|
}, listenerOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connection)
|
||||||
|
useEventListener(connection, 'change', updateNetworkInformation, listenerOptions);
|
||||||
|
|
||||||
|
updateNetworkInformation();
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSupported,
|
||||||
|
isOnline: shallowReadonly(isOnline),
|
||||||
|
saveData: shallowReadonly(saveData),
|
||||||
|
offlineAt: shallowReadonly(offlineAt),
|
||||||
|
onlineAt: shallowReadonly(onlineAt),
|
||||||
|
downlink: shallowReadonly(downlink),
|
||||||
|
downlinkMax: shallowReadonly(downlinkMax),
|
||||||
|
effectiveType: shallowReadonly(effectiveType),
|
||||||
|
rtt: shallowReadonly(rtt),
|
||||||
|
type: shallowReadonly(type),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope, nextTick, shallowRef } from 'vue';
|
||||||
|
import { useObjectUrl } from '.';
|
||||||
|
|
||||||
|
function createUrlStub() {
|
||||||
|
let counter = 0;
|
||||||
|
const createObjectURL = vi.fn((_object: Blob | MediaSource) => `blob:mock/${counter++}`);
|
||||||
|
const revokeObjectURL = vi.fn((_url: string) => {});
|
||||||
|
const window = { URL: { createObjectURL, revokeObjectURL } } as unknown as Window;
|
||||||
|
|
||||||
|
return { window, createObjectURL, revokeObjectURL };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeBlob(content = 'hello') {
|
||||||
|
return new Blob([content], { type: 'text/plain' });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(useObjectUrl, () => {
|
||||||
|
it('creates an object URL for an initial value', () => {
|
||||||
|
const { window, createObjectURL } = createUrlStub();
|
||||||
|
const blob = makeBlob();
|
||||||
|
const scope = effectScope();
|
||||||
|
|
||||||
|
let url: ReturnType<typeof useObjectUrl>;
|
||||||
|
scope.run(() => {
|
||||||
|
url = useObjectUrl(shallowRef(blob), { window });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createObjectURL).toHaveBeenCalledTimes(1);
|
||||||
|
expect(createObjectURL).toHaveBeenCalledWith(blob);
|
||||||
|
expect(url!.value).toBe('blob:mock/0');
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when the source is null/undefined', () => {
|
||||||
|
const { window, createObjectURL } = createUrlStub();
|
||||||
|
const scope = effectScope();
|
||||||
|
|
||||||
|
let url: ReturnType<typeof useObjectUrl>;
|
||||||
|
scope.run(() => {
|
||||||
|
url = useObjectUrl(shallowRef<Blob | undefined>(undefined), { window });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createObjectURL).not.toHaveBeenCalled();
|
||||||
|
expect(url!.value).toBeUndefined();
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('revokes the previous URL and creates a new one when the source changes', async () => {
|
||||||
|
const { window, createObjectURL, revokeObjectURL } = createUrlStub();
|
||||||
|
const source = shallowRef<Blob | undefined>(makeBlob('a'));
|
||||||
|
const scope = effectScope();
|
||||||
|
|
||||||
|
let url: ReturnType<typeof useObjectUrl>;
|
||||||
|
scope.run(() => {
|
||||||
|
url = useObjectUrl(source, { window });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(url!.value).toBe('blob:mock/0');
|
||||||
|
|
||||||
|
source.value = makeBlob('b');
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(revokeObjectURL).toHaveBeenCalledTimes(1);
|
||||||
|
expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock/0');
|
||||||
|
expect(createObjectURL).toHaveBeenCalledTimes(2);
|
||||||
|
expect(url!.value).toBe('blob:mock/1');
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('revokes and clears when the source becomes null', async () => {
|
||||||
|
const { window, revokeObjectURL } = createUrlStub();
|
||||||
|
const source = shallowRef<Blob | null>(makeBlob());
|
||||||
|
const scope = effectScope();
|
||||||
|
|
||||||
|
let url: ReturnType<typeof useObjectUrl>;
|
||||||
|
scope.run(() => {
|
||||||
|
url = useObjectUrl(source, { window });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(url!.value).toBe('blob:mock/0');
|
||||||
|
|
||||||
|
source.value = null;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock/0');
|
||||||
|
expect(url!.value).toBeUndefined();
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('revokes the active URL on scope dispose', () => {
|
||||||
|
const { window, revokeObjectURL } = createUrlStub();
|
||||||
|
const scope = effectScope();
|
||||||
|
|
||||||
|
let url: ReturnType<typeof useObjectUrl>;
|
||||||
|
scope.run(() => {
|
||||||
|
url = useObjectUrl(shallowRef(makeBlob()), { window });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(url!.value).toBe('blob:mock/0');
|
||||||
|
expect(revokeObjectURL).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
|
||||||
|
expect(revokeObjectURL).toHaveBeenCalledTimes(1);
|
||||||
|
expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock/0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a getter source', async () => {
|
||||||
|
const { window, createObjectURL } = createUrlStub();
|
||||||
|
const source = shallowRef<Blob | undefined>(undefined);
|
||||||
|
const scope = effectScope();
|
||||||
|
|
||||||
|
let url: ReturnType<typeof useObjectUrl>;
|
||||||
|
scope.run(() => {
|
||||||
|
url = useObjectUrl(() => source.value, { window });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(url!.value).toBeUndefined();
|
||||||
|
|
||||||
|
source.value = makeBlob();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(createObjectURL).toHaveBeenCalledTimes(1);
|
||||||
|
expect(url!.value).toBe('blob:mock/0');
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a read-only ref', () => {
|
||||||
|
const { window } = createUrlStub();
|
||||||
|
const scope = effectScope();
|
||||||
|
|
||||||
|
let url: ReturnType<typeof useObjectUrl>;
|
||||||
|
scope.run(() => {
|
||||||
|
url = useObjectUrl(shallowRef(makeBlob()), { window });
|
||||||
|
});
|
||||||
|
|
||||||
|
// shallowReadonly should warn and not mutate when written to
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
// @ts-expect-error read-only ref
|
||||||
|
url!.value = 'mutated';
|
||||||
|
expect(url!.value).toBe('blob:mock/0');
|
||||||
|
warn.mockRestore();
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing in an unsupported / SSR environment (no window)', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
|
||||||
|
// Simulate SSR: no `window` available. Destructuring defaults only kick in
|
||||||
|
// for `undefined`, so pass an explicit `null` to mirror `defaultWindow`
|
||||||
|
// being `undefined` on the server (the guard treats any falsy window the same).
|
||||||
|
let url: ReturnType<typeof useObjectUrl>;
|
||||||
|
scope.run(() => {
|
||||||
|
url = useObjectUrl(shallowRef(makeBlob()), { window: null as unknown as Window });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(url!.value).toBeUndefined();
|
||||||
|
|
||||||
|
// disposing must not throw even though there is no window
|
||||||
|
expect(() => scope.stop()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { shallowReadonly, shallowRef, toValue, watch } from 'vue';
|
||||||
|
import type { MaybeRefOrGetter, ShallowRef } from 'vue';
|
||||||
|
import { defaultWindow } from '@/types';
|
||||||
|
import type { ConfigurableWindow } from '@/types';
|
||||||
|
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||||
|
|
||||||
|
export interface UseObjectUrlOptions extends ConfigurableWindow {}
|
||||||
|
|
||||||
|
export type UseObjectUrlReturn = Readonly<ShallowRef<string | undefined>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useObjectUrl
|
||||||
|
* @category Browser
|
||||||
|
* @description Create and auto-revoke an object URL for a `Blob`, `File`, or `MediaSource`. The previous URL is revoked whenever the source changes, and the active URL is revoked on scope dispose.
|
||||||
|
*
|
||||||
|
* @param {MaybeRefOrGetter<Blob | MediaSource | null | undefined>} object The reactive source to create an object URL for
|
||||||
|
* @param {UseObjectUrlOptions} [options={}] Options
|
||||||
|
* @returns {UseObjectUrlReturn} A read-only ref holding the current object URL, or `undefined` when there is no source (or in unsupported/SSR environments)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const file = shallowRef<File>();
|
||||||
|
* const url = useObjectUrl(file);
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useObjectUrl(
|
||||||
|
object: MaybeRefOrGetter<Blob | MediaSource | null | undefined>,
|
||||||
|
options: UseObjectUrlOptions = {},
|
||||||
|
): UseObjectUrlReturn {
|
||||||
|
const { window = defaultWindow } = options;
|
||||||
|
|
||||||
|
const url = shallowRef<string | undefined>();
|
||||||
|
|
||||||
|
const release = (): void => {
|
||||||
|
if (url.value)
|
||||||
|
(window as (Window & typeof globalThis) | undefined)?.URL.revokeObjectURL(url.value);
|
||||||
|
|
||||||
|
url.value = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => toValue(object),
|
||||||
|
(newObject) => {
|
||||||
|
release();
|
||||||
|
|
||||||
|
if (newObject && window)
|
||||||
|
url.value = (window as Window & typeof globalThis).URL.createObjectURL(newObject);
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
tryOnScopeDispose(release);
|
||||||
|
|
||||||
|
return shallowReadonly(url);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope, nextTick } from 'vue';
|
||||||
|
import { useOnline } from '.';
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
describe(useOnline, () => {
|
||||||
|
it('reflects the initial navigator.onLine', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let online: ReturnType<typeof useOnline>;
|
||||||
|
scope.run(() => {
|
||||||
|
online = useOnline();
|
||||||
|
});
|
||||||
|
expect(online!.value).toBe(navigator.onLine);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates on offline/online events', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let online: ReturnType<typeof useOnline>;
|
||||||
|
scope.run(() => {
|
||||||
|
online = useOnline();
|
||||||
|
});
|
||||||
|
|
||||||
|
globalThis.dispatchEvent(new Event('offline'));
|
||||||
|
await nextTick();
|
||||||
|
expect(online!.value).toBeFalsy();
|
||||||
|
|
||||||
|
globalThis.dispatchEvent(new Event('online'));
|
||||||
|
await nextTick();
|
||||||
|
expect(online!.value).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to true when there is no window (SSR)', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let online: ReturnType<typeof useOnline>;
|
||||||
|
scope.run(() => {
|
||||||
|
online = useOnline({ window: undefined as any });
|
||||||
|
});
|
||||||
|
expect(online!.value).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { shallowReadonly, shallowRef } from 'vue';
|
||||||
|
import type { ShallowRef } from 'vue';
|
||||||
|
import { defaultWindow } from '@/types';
|
||||||
|
import type { ConfigurableWindow } from '@/types';
|
||||||
|
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||||
|
|
||||||
|
export interface UseOnlineOptions extends ConfigurableWindow {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useOnline
|
||||||
|
* @category Browser
|
||||||
|
* @description Reactive online/offline status based on `navigator.onLine`.
|
||||||
|
* For connection details (effectiveType, downlink, saveData, transition
|
||||||
|
* timestamps, ...) use {@link useNetwork} instead.
|
||||||
|
*
|
||||||
|
* @param {UseOnlineOptions} [options={}] Options
|
||||||
|
* @returns {Readonly<ShallowRef<boolean>>} Whether the browser is online
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const online = useOnline();
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useOnline(options: UseOnlineOptions = {}): Readonly<ShallowRef<boolean>> {
|
||||||
|
const { window = defaultWindow } = options;
|
||||||
|
|
||||||
|
const isOnline = shallowRef(window?.navigator?.onLine ?? true);
|
||||||
|
|
||||||
|
useEventListener(window, 'online', () => {
|
||||||
|
isOnline.value = true;
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
useEventListener(window, 'offline', () => {
|
||||||
|
isOnline.value = false;
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
return shallowReadonly(isOnline);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope, isReadonly, nextTick } from 'vue';
|
||||||
|
import { usePageLeave } from '.';
|
||||||
|
|
||||||
|
function withScope<T>(fn: () => T): { value: T; stop: () => void } {
|
||||||
|
const scope = effectScope();
|
||||||
|
let value!: T;
|
||||||
|
scope.run(() => {
|
||||||
|
value = fn();
|
||||||
|
});
|
||||||
|
return { value, stop: () => scope.stop() };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(usePageLeave, () => {
|
||||||
|
it('is false initially', () => {
|
||||||
|
const { value: isLeft, stop } = withScope(() => usePageLeave());
|
||||||
|
expect(isLeft.value).toBeFalsy();
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a writable shallow ref', () => {
|
||||||
|
const { value: isLeft, stop } = withScope(() => usePageLeave());
|
||||||
|
expect(isReadonly(isLeft)).toBeFalsy();
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('becomes true when the pointer leaves the document', async () => {
|
||||||
|
const { value: isLeft, stop } = withScope(() => usePageLeave());
|
||||||
|
|
||||||
|
document.documentElement.dispatchEvent(new MouseEvent('mouseleave'));
|
||||||
|
await nextTick();
|
||||||
|
expect(isLeft.value).toBeTruthy();
|
||||||
|
|
||||||
|
document.documentElement.dispatchEvent(new MouseEvent('mouseenter'));
|
||||||
|
await nextTick();
|
||||||
|
expect(isLeft.value).toBeFalsy();
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses mouseout relatedTarget to detect leaving', async () => {
|
||||||
|
const { value: isLeft, stop } = withScope(() => usePageLeave());
|
||||||
|
|
||||||
|
// No relatedTarget => pointer left the page.
|
||||||
|
globalThis.dispatchEvent(new MouseEvent('mouseout'));
|
||||||
|
await nextTick();
|
||||||
|
expect(isLeft.value).toBeTruthy();
|
||||||
|
|
||||||
|
// relatedTarget present => pointer moved to another element, still on page.
|
||||||
|
globalThis.dispatchEvent(new MouseEvent('mouseout', { relatedTarget: document.body }));
|
||||||
|
await nextTick();
|
||||||
|
expect(isLeft.value).toBeFalsy();
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invokes onChange with the new value and event only on change', async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const { stop } = withScope(() => usePageLeave({ onChange }));
|
||||||
|
|
||||||
|
document.documentElement.dispatchEvent(new MouseEvent('mouseleave'));
|
||||||
|
await nextTick();
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(true, expect.any(MouseEvent));
|
||||||
|
|
||||||
|
// Repeated leave should not fire again (no state change).
|
||||||
|
document.documentElement.dispatchEvent(new MouseEvent('mouseleave'));
|
||||||
|
await nextTick();
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
document.documentElement.dispatchEvent(new MouseEvent('mouseenter'));
|
||||||
|
await nextTick();
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(2);
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(false, expect.any(MouseEvent));
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw and stays false when window is undefined (SSR)', () => {
|
||||||
|
const { value: isLeft, stop } = withScope(() =>
|
||||||
|
usePageLeave({ window: undefined }),
|
||||||
|
);
|
||||||
|
expect(isLeft.value).toBeFalsy();
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('binds listeners to a custom window', async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const { stop } = withScope(() => usePageLeave({ window: globalThis as unknown as Window, onChange }));
|
||||||
|
|
||||||
|
document.documentElement.dispatchEvent(new MouseEvent('mouseleave'));
|
||||||
|
await nextTick();
|
||||||
|
expect(onChange).toHaveBeenCalledWith(true, expect.any(MouseEvent));
|
||||||
|
document.documentElement.dispatchEvent(new MouseEvent('mouseenter'));
|
||||||
|
await nextTick();
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { shallowRef } from 'vue';
|
||||||
|
import type { ShallowRef } from 'vue';
|
||||||
|
import { defaultWindow } from '@/types';
|
||||||
|
import type { ConfigurableWindow } from '@/types';
|
||||||
|
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||||
|
|
||||||
|
export interface UsePageLeaveOptions extends ConfigurableWindow {
|
||||||
|
/**
|
||||||
|
* Called whenever the leave state flips, receiving the new value and the
|
||||||
|
* originating mouse event.
|
||||||
|
*
|
||||||
|
* @default undefined
|
||||||
|
*/
|
||||||
|
onChange?: (isLeft: boolean, event: MouseEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UsePageLeaveReturn = ShallowRef<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name usePageLeave
|
||||||
|
* @category Browser
|
||||||
|
* @description Reactive flag indicating whether the mouse has left the page.
|
||||||
|
*
|
||||||
|
* @param {UsePageLeaveOptions} [options={}] Options (custom `window`, `onChange` callback)
|
||||||
|
* @returns {UsePageLeaveReturn} Whether the pointer has left the page
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const hasLeft = usePageLeave();
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* usePageLeave({
|
||||||
|
* onChange: (isLeft) => {
|
||||||
|
* if (isLeft) showExitIntentModal();
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function usePageLeave(options: UsePageLeaveOptions = {}): UsePageLeaveReturn {
|
||||||
|
const { window = defaultWindow, onChange } = options;
|
||||||
|
|
||||||
|
const isLeft = shallowRef(false);
|
||||||
|
|
||||||
|
const update = (left: boolean, event: MouseEvent) => {
|
||||||
|
if (left === isLeft.value)
|
||||||
|
return;
|
||||||
|
|
||||||
|
isLeft.value = left;
|
||||||
|
onChange?.(left, event);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (window) {
|
||||||
|
const documentElement = window.document.documentElement;
|
||||||
|
const listenerOptions = { passive: true } as const;
|
||||||
|
|
||||||
|
useEventListener(window, 'mouseout', (event) => {
|
||||||
|
const from = event.relatedTarget || (event as MouseEvent & { toElement?: EventTarget }).toElement;
|
||||||
|
update(!from, event);
|
||||||
|
}, listenerOptions);
|
||||||
|
|
||||||
|
useEventListener(documentElement, 'mouseleave', (event) => {
|
||||||
|
update(true, event);
|
||||||
|
}, listenerOptions);
|
||||||
|
|
||||||
|
useEventListener(documentElement, 'mouseenter', (event) => {
|
||||||
|
update(false, event);
|
||||||
|
}, listenerOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return isLeft;
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope } from 'vue';
|
||||||
|
import type { UsePermissionReturn } from '.';
|
||||||
|
import { usePermission } from '.';
|
||||||
|
|
||||||
|
function stubPermissions(state: PermissionState) {
|
||||||
|
let changeHandler: ((this: PermissionStatus, ev: Event) => any) | undefined;
|
||||||
|
const status = {
|
||||||
|
state,
|
||||||
|
addEventListener: vi.fn((_: string, handler: any) => { changeHandler = handler; }),
|
||||||
|
removeEventListener: vi.fn(() => { changeHandler = undefined; }),
|
||||||
|
};
|
||||||
|
const query = vi.fn(async () => status);
|
||||||
|
const navigator = { permissions: { query } } as unknown as Navigator;
|
||||||
|
const emitChange = (next: PermissionState) => {
|
||||||
|
status.state = next;
|
||||||
|
changeHandler?.call(status as unknown as PermissionStatus, new Event('change'));
|
||||||
|
};
|
||||||
|
return { navigator, status, query, emitChange, getChangeHandler: () => changeHandler };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(usePermission, () => {
|
||||||
|
it('resolves the permission state', async () => {
|
||||||
|
const { navigator } = stubPermissions('granted');
|
||||||
|
const scope = effectScope();
|
||||||
|
let state: UsePermissionReturn;
|
||||||
|
scope.run(() => {
|
||||||
|
state = usePermission('geolocation', { navigator });
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(state!.value).toBe('granted'));
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes controls when controls: true', async () => {
|
||||||
|
const { navigator, query } = stubPermissions('prompt');
|
||||||
|
const scope = effectScope();
|
||||||
|
let result: any;
|
||||||
|
scope.run(() => {
|
||||||
|
result = usePermission('camera', { controls: true, navigator });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.isSupported.value).toBeTruthy();
|
||||||
|
await result.query();
|
||||||
|
expect(query).toHaveBeenCalled();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined state when unsupported', () => {
|
||||||
|
const navigator = {} as Navigator;
|
||||||
|
const scope = effectScope();
|
||||||
|
let state: UsePermissionReturn;
|
||||||
|
scope.run(() => {
|
||||||
|
state = usePermission('geolocation', { navigator });
|
||||||
|
});
|
||||||
|
expect(state!.value).toBeUndefined();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reacts to the status change event', async () => {
|
||||||
|
const { navigator, emitChange } = stubPermissions('prompt');
|
||||||
|
const scope = effectScope();
|
||||||
|
let state: UsePermissionReturn;
|
||||||
|
scope.run(() => {
|
||||||
|
state = usePermission('notifications', { navigator });
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(state!.value).toBe('prompt'));
|
||||||
|
|
||||||
|
emitChange('granted');
|
||||||
|
await vi.waitFor(() => expect(state!.value).toBe('granted'));
|
||||||
|
|
||||||
|
emitChange('denied');
|
||||||
|
await vi.waitFor(() => expect(state!.value).toBe('denied'));
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('binds the change listener exactly once after resolution', async () => {
|
||||||
|
const { navigator, status } = stubPermissions('granted');
|
||||||
|
const scope = effectScope();
|
||||||
|
let state: UsePermissionReturn;
|
||||||
|
scope.run(() => {
|
||||||
|
state = usePermission('camera', { navigator });
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(state!.value).toBe('granted'));
|
||||||
|
expect(status.addEventListener).toHaveBeenCalledTimes(1);
|
||||||
|
expect(status.addEventListener).toHaveBeenCalledWith('change', expect.any(Function), { passive: true });
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes the change listener when the scope is disposed', async () => {
|
||||||
|
const { navigator, status } = stubPermissions('granted');
|
||||||
|
const scope = effectScope();
|
||||||
|
let state: UsePermissionReturn;
|
||||||
|
scope.run(() => {
|
||||||
|
state = usePermission('camera', { navigator });
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(state!.value).toBe('granted'));
|
||||||
|
scope.stop();
|
||||||
|
expect(status.removeEventListener).toHaveBeenCalledWith('change', expect.any(Function), { passive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dedupes concurrent and repeated queries', async () => {
|
||||||
|
const { navigator, query } = stubPermissions('granted');
|
||||||
|
const scope = effectScope();
|
||||||
|
let result: any;
|
||||||
|
scope.run(() => {
|
||||||
|
result = usePermission('microphone', { controls: true, navigator });
|
||||||
|
});
|
||||||
|
|
||||||
|
// initial query() call from setup + two more concurrent calls
|
||||||
|
await Promise.all([result.query(), result.query()]);
|
||||||
|
// once resolved, subsequent calls reuse the cached status
|
||||||
|
await result.query();
|
||||||
|
|
||||||
|
expect(query).toHaveBeenCalledTimes(1);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('query resolves to the raw PermissionStatus with controls', async () => {
|
||||||
|
const { navigator, status } = stubPermissions('granted');
|
||||||
|
const scope = effectScope();
|
||||||
|
let result: any;
|
||||||
|
scope.run(() => {
|
||||||
|
result = usePermission('geolocation', { controls: true, navigator });
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = await result.query();
|
||||||
|
expect(resolved).toBe(status);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('query resolves to undefined when unsupported', async () => {
|
||||||
|
const navigator = {} as Navigator;
|
||||||
|
const scope = effectScope();
|
||||||
|
let result: any;
|
||||||
|
scope.run(() => {
|
||||||
|
result = usePermission('geolocation', { controls: true, navigator });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.isSupported.value).toBeFalsy();
|
||||||
|
await expect(result.query()).resolves.toBeUndefined();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to "prompt" when the query rejects', async () => {
|
||||||
|
const query = vi.fn(async () => {
|
||||||
|
throw new TypeError('denied descriptor');
|
||||||
|
});
|
||||||
|
const navigator = { permissions: { query } } as unknown as Navigator;
|
||||||
|
const scope = effectScope();
|
||||||
|
let state: UsePermissionReturn;
|
||||||
|
scope.run(() => {
|
||||||
|
state = usePermission('push', { navigator });
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(state!.value).toBe('prompt'));
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a descriptor object', async () => {
|
||||||
|
const { navigator, query } = stubPermissions('granted');
|
||||||
|
const scope = effectScope();
|
||||||
|
let state: UsePermissionReturn;
|
||||||
|
scope.run(() => {
|
||||||
|
state = usePermission({ name: 'push', userVisibleOnly: true } as PermissionDescriptor, { navigator });
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(state!.value).toBe('granted'));
|
||||||
|
expect(query).toHaveBeenCalledWith({ name: 'push', userVisibleOnly: true });
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { shallowRef, toRaw } from 'vue';
|
||||||
|
import type { Ref, ShallowRef } from 'vue';
|
||||||
|
import { isString } from '@robonen/stdlib';
|
||||||
|
import { defaultNavigator } from '@/types';
|
||||||
|
import type { ConfigurableNavigator } from '@/types';
|
||||||
|
import { useSupported } from '@/composables/browser/useSupported';
|
||||||
|
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permission names not yet present in the lib DOM `PermissionName` union but
|
||||||
|
* supported by browsers behind the Permissions API.
|
||||||
|
*/
|
||||||
|
export type PermissionDescriptorNamePolyfill
|
||||||
|
= | 'accelerometer'
|
||||||
|
| 'accessibility-events'
|
||||||
|
| 'ambient-light-sensor'
|
||||||
|
| 'background-sync'
|
||||||
|
| 'camera'
|
||||||
|
| 'clipboard-read'
|
||||||
|
| 'clipboard-write'
|
||||||
|
| 'geolocation'
|
||||||
|
| 'gyroscope'
|
||||||
|
| 'local-fonts'
|
||||||
|
| 'magnetometer'
|
||||||
|
| 'microphone'
|
||||||
|
| 'midi'
|
||||||
|
| 'notifications'
|
||||||
|
| 'payment-handler'
|
||||||
|
| 'persistent-storage'
|
||||||
|
| 'push'
|
||||||
|
| 'screen-wake-lock'
|
||||||
|
| 'speaker'
|
||||||
|
| 'speaker-selection'
|
||||||
|
| 'storage-access'
|
||||||
|
| 'window-management';
|
||||||
|
|
||||||
|
export type GeneralPermissionDescriptor
|
||||||
|
= | PermissionDescriptor
|
||||||
|
| { name: PermissionDescriptorNamePolyfill };
|
||||||
|
|
||||||
|
export interface UsePermissionOptions<Controls extends boolean> extends ConfigurableNavigator {
|
||||||
|
/**
|
||||||
|
* Expose the `isSupported` flag and a `query` method that returns the raw `PermissionStatus`
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
controls?: Controls;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UsePermissionReturn = Readonly<Ref<PermissionState | undefined>>;
|
||||||
|
|
||||||
|
export interface UsePermissionReturnWithControls {
|
||||||
|
/**
|
||||||
|
* Reactive permission state (`granted` | `denied` | `prompt`), or `undefined` while unsupported/unresolved
|
||||||
|
*/
|
||||||
|
state: UsePermissionReturn;
|
||||||
|
/**
|
||||||
|
* Whether the Permissions API is available
|
||||||
|
*/
|
||||||
|
isSupported: Readonly<Ref<boolean>>;
|
||||||
|
/**
|
||||||
|
* Query (or re-query) the permission, resolving to the raw `PermissionStatus`
|
||||||
|
*/
|
||||||
|
query: () => Promise<PermissionStatus | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name usePermission
|
||||||
|
* @category Browser
|
||||||
|
* @description Reactive Permissions API state.
|
||||||
|
*
|
||||||
|
* @param {GeneralPermissionDescriptor | string} permissionDesc The permission to query
|
||||||
|
* @param {UsePermissionOptions} [options={}] Options
|
||||||
|
* @returns {UsePermissionReturn | UsePermissionReturnWithControls} The permission state, or controls when `controls: true`
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const microphone = usePermission('microphone');
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { state, isSupported, query } = usePermission('camera', { controls: true });
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function usePermission(
|
||||||
|
permissionDesc: GeneralPermissionDescriptor | GeneralPermissionDescriptor['name'],
|
||||||
|
options?: UsePermissionOptions<false>,
|
||||||
|
): UsePermissionReturn;
|
||||||
|
export function usePermission(
|
||||||
|
permissionDesc: GeneralPermissionDescriptor | GeneralPermissionDescriptor['name'],
|
||||||
|
options: UsePermissionOptions<true>,
|
||||||
|
): UsePermissionReturnWithControls;
|
||||||
|
export function usePermission(
|
||||||
|
permissionDesc: GeneralPermissionDescriptor | GeneralPermissionDescriptor['name'],
|
||||||
|
options: UsePermissionOptions<boolean> = {},
|
||||||
|
): UsePermissionReturn | UsePermissionReturnWithControls {
|
||||||
|
const { controls = false, navigator = defaultNavigator } = options;
|
||||||
|
|
||||||
|
const isSupported = useSupported(() => !!navigator && 'permissions' in navigator);
|
||||||
|
|
||||||
|
const desc = (isString(permissionDesc)
|
||||||
|
? { name: permissionDesc }
|
||||||
|
: permissionDesc) as PermissionDescriptor;
|
||||||
|
|
||||||
|
// Shallow refs: `PermissionStatus` is a host object, deep reactivity is wasteful.
|
||||||
|
const permissionStatus: ShallowRef<PermissionStatus | undefined> = shallowRef();
|
||||||
|
const state: ShallowRef<PermissionState | undefined> = shallowRef();
|
||||||
|
|
||||||
|
const update = (): void => {
|
||||||
|
state.value = permissionStatus.value?.state ?? 'prompt';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register the `change` listener synchronously against the reactive ref so it
|
||||||
|
// auto-rebinds when the status resolves and auto-cleans on scope dispose.
|
||||||
|
useEventListener(permissionStatus, 'change', update, { passive: true });
|
||||||
|
|
||||||
|
// Dedupe concurrent/repeat calls: once a query is in flight we reuse it.
|
||||||
|
let queryPromise: Promise<PermissionStatus | undefined> | undefined;
|
||||||
|
|
||||||
|
const query = (): Promise<PermissionStatus | undefined> => {
|
||||||
|
if (!isSupported.value)
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
|
||||||
|
if (permissionStatus.value)
|
||||||
|
return Promise.resolve(permissionStatus.value);
|
||||||
|
|
||||||
|
if (queryPromise)
|
||||||
|
return queryPromise;
|
||||||
|
|
||||||
|
queryPromise = navigator!.permissions
|
||||||
|
.query(desc)
|
||||||
|
.then((status) => {
|
||||||
|
permissionStatus.value = status;
|
||||||
|
return status;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
permissionStatus.value = undefined;
|
||||||
|
return undefined;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
update();
|
||||||
|
queryPromise = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
return queryPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
query();
|
||||||
|
|
||||||
|
if (controls) {
|
||||||
|
return {
|
||||||
|
state: state as UsePermissionReturn,
|
||||||
|
isSupported,
|
||||||
|
// `toRaw` so callers get the underlying `PermissionStatus`, not a reactive proxy.
|
||||||
|
query: () => query().then(status => (status ? toRaw(status) : undefined)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return state as UsePermissionReturn;
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope, nextTick, shallowRef } from 'vue';
|
||||||
|
import { usePointer } from '.';
|
||||||
|
|
||||||
|
interface PointerProps {
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
pressure?: number;
|
||||||
|
pointerId?: number;
|
||||||
|
tiltX?: number;
|
||||||
|
tiltY?: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
twist?: number;
|
||||||
|
pointerType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchPointer(target: EventTarget, type: string, props: PointerProps = {}) {
|
||||||
|
const event = new Event(type, { bubbles: true });
|
||||||
|
for (const [key, value] of Object.entries(props))
|
||||||
|
Object.defineProperty(event, key, { value, configurable: true });
|
||||||
|
target.dispatchEvent(event);
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(usePointer, () => {
|
||||||
|
it('starts from the default state', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let ptr: ReturnType<typeof usePointer>;
|
||||||
|
scope.run(() => {
|
||||||
|
ptr = usePointer();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ptr!.x.value).toBe(0);
|
||||||
|
expect(ptr!.y.value).toBe(0);
|
||||||
|
expect(ptr!.pressure.value).toBe(0);
|
||||||
|
expect(ptr!.pointerId.value).toBe(0);
|
||||||
|
expect(ptr!.tiltX.value).toBe(0);
|
||||||
|
expect(ptr!.tiltY.value).toBe(0);
|
||||||
|
expect(ptr!.width.value).toBe(0);
|
||||||
|
expect(ptr!.height.value).toBe(0);
|
||||||
|
expect(ptr!.twist.value).toBe(0);
|
||||||
|
expect(ptr!.pointerType.value).toBe(null);
|
||||||
|
expect(ptr!.isInside.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges the initial value over defaults', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let ptr: ReturnType<typeof usePointer>;
|
||||||
|
scope.run(() => {
|
||||||
|
ptr = usePointer({ initialValue: { x: 12, pressure: 0.5 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ptr!.x.value).toBe(12);
|
||||||
|
expect(ptr!.pressure.value).toBe(0.5);
|
||||||
|
expect(ptr!.y.value).toBe(0);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks pointermove and populates the full state', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let ptr: ReturnType<typeof usePointer>;
|
||||||
|
scope.run(() => {
|
||||||
|
ptr = usePointer();
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatchPointer(globalThis, 'pointermove', {
|
||||||
|
x: 100,
|
||||||
|
y: 200,
|
||||||
|
pressure: 0.7,
|
||||||
|
pointerId: 3,
|
||||||
|
tiltX: 10,
|
||||||
|
tiltY: -5,
|
||||||
|
width: 4,
|
||||||
|
height: 6,
|
||||||
|
twist: 90,
|
||||||
|
pointerType: 'pen',
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(ptr!.x.value).toBe(100);
|
||||||
|
expect(ptr!.y.value).toBe(200);
|
||||||
|
expect(ptr!.pressure.value).toBe(0.7);
|
||||||
|
expect(ptr!.pointerId.value).toBe(3);
|
||||||
|
expect(ptr!.tiltX.value).toBe(10);
|
||||||
|
expect(ptr!.tiltY.value).toBe(-5);
|
||||||
|
expect(ptr!.width.value).toBe(4);
|
||||||
|
expect(ptr!.height.value).toBe(6);
|
||||||
|
expect(ptr!.twist.value).toBe(90);
|
||||||
|
expect(ptr!.pointerType.value).toBe('pen');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets isInside true on pointer activity and false on pointerleave', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let ptr: ReturnType<typeof usePointer>;
|
||||||
|
scope.run(() => {
|
||||||
|
ptr = usePointer();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ptr!.isInside.value).toBeFalsy();
|
||||||
|
|
||||||
|
dispatchPointer(globalThis, 'pointerdown', { x: 1, y: 2, pointerType: 'mouse' });
|
||||||
|
await nextTick();
|
||||||
|
expect(ptr!.isInside.value).toBeTruthy();
|
||||||
|
|
||||||
|
dispatchPointer(globalThis, 'pointerleave');
|
||||||
|
await nextTick();
|
||||||
|
expect(ptr!.isInside.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks pointerdown and pointerup', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let ptr: ReturnType<typeof usePointer>;
|
||||||
|
scope.run(() => {
|
||||||
|
ptr = usePointer();
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatchPointer(globalThis, 'pointerdown', { x: 5, y: 5, pointerType: 'mouse' });
|
||||||
|
await nextTick();
|
||||||
|
expect(ptr!.x.value).toBe(5);
|
||||||
|
|
||||||
|
dispatchPointer(globalThis, 'pointerup', { x: 9, y: 9, pointerType: 'mouse' });
|
||||||
|
await nextTick();
|
||||||
|
expect(ptr!.x.value).toBe(9);
|
||||||
|
expect(ptr!.y.value).toBe(9);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores events whose pointerType is not allowed', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let ptr: ReturnType<typeof usePointer>;
|
||||||
|
scope.run(() => {
|
||||||
|
ptr = usePointer({ pointerTypes: ['pen'], initialValue: { x: 1, y: 2 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatchPointer(globalThis, 'pointermove', { x: 50, y: 60, pointerType: 'mouse' });
|
||||||
|
await nextTick();
|
||||||
|
// mouse rejected: position unchanged, but isInside still flips true
|
||||||
|
expect(ptr!.x.value).toBe(1);
|
||||||
|
expect(ptr!.y.value).toBe(2);
|
||||||
|
expect(ptr!.isInside.value).toBeTruthy();
|
||||||
|
|
||||||
|
dispatchPointer(globalThis, 'pointermove', { x: 50, y: 60, pointerType: 'pen' });
|
||||||
|
await nextTick();
|
||||||
|
expect(ptr!.x.value).toBe(50);
|
||||||
|
expect(ptr!.y.value).toBe(60);
|
||||||
|
expect(ptr!.pointerType.value).toBe('pen');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attaches passive listeners', () => {
|
||||||
|
const addSpy = vi.spyOn(globalThis, 'addEventListener');
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => {
|
||||||
|
usePointer();
|
||||||
|
});
|
||||||
|
|
||||||
|
const moveCall = addSpy.mock.calls.find(([name]) => name === 'pointermove');
|
||||||
|
expect(moveCall).toBeDefined();
|
||||||
|
expect((moveCall![2] as AddEventListenerOptions).passive).toBeTruthy();
|
||||||
|
|
||||||
|
addSpy.mockRestore();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listens on a custom element target', async () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const elRef = shallowRef(el);
|
||||||
|
const addSpy = vi.spyOn(el, 'addEventListener');
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let ptr: ReturnType<typeof usePointer>;
|
||||||
|
scope.run(() => {
|
||||||
|
ptr = usePointer({ target: elRef });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(addSpy.mock.calls.some(([name]) => name === 'pointermove')).toBeTruthy();
|
||||||
|
|
||||||
|
dispatchPointer(el, 'pointermove', { x: 7, y: 8, pointerType: 'mouse' });
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(ptr!.x.value).toBe(7);
|
||||||
|
expect(ptr!.y.value).toBe(8);
|
||||||
|
|
||||||
|
addSpy.mockRestore();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes writable state refs', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let ptr: ReturnType<typeof usePointer>;
|
||||||
|
scope.run(() => {
|
||||||
|
ptr = usePointer();
|
||||||
|
});
|
||||||
|
|
||||||
|
ptr!.x.value = 42;
|
||||||
|
expect(ptr!.x.value).toBe(42);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when window is unavailable (SSR)', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let ptr: ReturnType<typeof usePointer>;
|
||||||
|
scope.run(() => {
|
||||||
|
ptr = usePointer({ window: undefined, target: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ptr!.x.value).toBe(0);
|
||||||
|
expect(ptr!.y.value).toBe(0);
|
||||||
|
expect(ptr!.pointerType.value).toBe(null);
|
||||||
|
expect(ptr!.isInside.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { computed, shallowRef } from 'vue';
|
||||||
|
import type { ShallowRef, WritableComputedRef } from 'vue';
|
||||||
|
import { pick } from '@robonen/stdlib';
|
||||||
|
import { defaultWindow } from '@/types';
|
||||||
|
import type { ConfigurableWindow } from '@/types';
|
||||||
|
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||||
|
import { unrefElement } from '@/composables/component/unrefElement';
|
||||||
|
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
||||||
|
|
||||||
|
export type UsePointerType = 'mouse' | 'touch' | 'pen';
|
||||||
|
|
||||||
|
export interface UsePointerState {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
pressure: number;
|
||||||
|
pointerId: number;
|
||||||
|
tiltX: number;
|
||||||
|
tiltY: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
twist: number;
|
||||||
|
pointerType: UsePointerType | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsePointerOptions extends ConfigurableWindow {
|
||||||
|
/**
|
||||||
|
* Pointer types that should be listened to.
|
||||||
|
*
|
||||||
|
* @default ['mouse', 'touch', 'pen']
|
||||||
|
*/
|
||||||
|
pointerTypes?: UsePointerType[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initial pointer state.
|
||||||
|
*/
|
||||||
|
initialValue?: Partial<UsePointerState>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Target to attach the listeners to. Accepts a window, document, element ref,
|
||||||
|
* getter, or component instance.
|
||||||
|
*
|
||||||
|
* @default window
|
||||||
|
*/
|
||||||
|
target?: MaybeComputedElementRef | Window | Document;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsePointerReturn {
|
||||||
|
x: WritableComputedRef<number>;
|
||||||
|
y: WritableComputedRef<number>;
|
||||||
|
pressure: WritableComputedRef<number>;
|
||||||
|
pointerId: WritableComputedRef<number>;
|
||||||
|
tiltX: WritableComputedRef<number>;
|
||||||
|
tiltY: WritableComputedRef<number>;
|
||||||
|
width: WritableComputedRef<number>;
|
||||||
|
height: WritableComputedRef<number>;
|
||||||
|
twist: WritableComputedRef<number>;
|
||||||
|
pointerType: WritableComputedRef<UsePointerType | null>;
|
||||||
|
isInside: ShallowRef<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultState: UsePointerState = {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
pointerId: 0,
|
||||||
|
pressure: 0,
|
||||||
|
tiltX: 0,
|
||||||
|
tiltY: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
twist: 0,
|
||||||
|
pointerType: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const keys = Object.keys(defaultState) as Array<keyof UsePointerState>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name usePointer
|
||||||
|
* @category Browser
|
||||||
|
* @description Reactive pointer state (position, pressure, tilt, size, and
|
||||||
|
* pointer type) sourced from pointer events on a target, plus whether the
|
||||||
|
* pointer is currently inside it.
|
||||||
|
*
|
||||||
|
* @param {UsePointerOptions} [options={}] Options
|
||||||
|
* @returns {UsePointerReturn} Reactive pointer state refs and `isInside`
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { x, y, pressure, pointerType, isInside } = usePointer();
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Track a specific element, pen only
|
||||||
|
* const { x, y, tiltX, tiltY } = usePointer({ target: el, pointerTypes: ['pen'] });
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function usePointer(options: UsePointerOptions = {}): UsePointerReturn {
|
||||||
|
const {
|
||||||
|
pointerTypes,
|
||||||
|
initialValue = {},
|
||||||
|
window = defaultWindow,
|
||||||
|
target = window,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const isInside = shallowRef(false);
|
||||||
|
|
||||||
|
const state = shallowRef<UsePointerState>({
|
||||||
|
...defaultState,
|
||||||
|
...initialValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handler = (event: PointerEvent) => {
|
||||||
|
isInside.value = true;
|
||||||
|
|
||||||
|
if (pointerTypes && !pointerTypes.includes(event.pointerType as UsePointerType))
|
||||||
|
return;
|
||||||
|
|
||||||
|
state.value = pick(event, keys) as UsePointerState;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A raw window/document/EventTarget is used directly (fast, non-reactive path
|
||||||
|
// in useEventListener). Refs/getters/element instances are resolved lazily via
|
||||||
|
// a getter so the listeners re-bind when the underlying element changes.
|
||||||
|
const listenTarget = isTarget(target)
|
||||||
|
? target
|
||||||
|
: (): EventTarget | null | undefined => unrefElement(target as MaybeComputedElementRef) as EventTarget | null | undefined;
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
const listenerOptions = { passive: true };
|
||||||
|
|
||||||
|
useEventListener(listenTarget, ['pointerdown', 'pointermove', 'pointerup'], handler as (e: Event) => void, listenerOptions);
|
||||||
|
useEventListener(listenTarget, 'pointerleave', () => (isInside.value = false), listenerOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive a writable ref per field that reads/writes through the single
|
||||||
|
// shallowRef holding the whole state, matching VueUse's `toRefs(shallowRef)`.
|
||||||
|
const toField = <K extends keyof UsePointerState>(key: K): WritableComputedRef<UsePointerState[K]> =>
|
||||||
|
computed({
|
||||||
|
get: () => state.value[key],
|
||||||
|
set: value => (state.value = { ...state.value, [key]: value }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: toField('x'),
|
||||||
|
y: toField('y'),
|
||||||
|
pressure: toField('pressure'),
|
||||||
|
pointerId: toField('pointerId'),
|
||||||
|
tiltX: toField('tiltX'),
|
||||||
|
tiltY: toField('tiltY'),
|
||||||
|
width: toField('width'),
|
||||||
|
height: toField('height'),
|
||||||
|
twist: toField('twist'),
|
||||||
|
pointerType: toField('pointerType'),
|
||||||
|
isInside,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `true` for an object that is itself an event target (window/document/element)
|
||||||
|
* and should be attached to directly, rather than unwrapped from a ref/getter.
|
||||||
|
*/
|
||||||
|
function isTarget(value: unknown): value is EventTarget {
|
||||||
|
return typeof value === 'object' && value !== null && 'addEventListener' in value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope, nextTick } from 'vue';
|
||||||
|
import { usePreferredColorScheme } from '.';
|
||||||
|
|
||||||
|
function stubScheme(scheme: 'dark' | 'light' | 'none') {
|
||||||
|
vi.stubGlobal('matchMedia', vi.fn((media: string) => ({
|
||||||
|
matches: scheme !== 'none' && media.includes(scheme),
|
||||||
|
media,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(usePreferredColorScheme, () => {
|
||||||
|
beforeEach(() => vi.stubGlobal('matchMedia', undefined));
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('returns dark when dark is preferred', async () => {
|
||||||
|
stubScheme('dark');
|
||||||
|
const scope = effectScope();
|
||||||
|
let scheme: ReturnType<typeof usePreferredColorScheme>;
|
||||||
|
scope.run(() => {
|
||||||
|
scheme = usePreferredColorScheme();
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
expect(scheme!.value).toBe('dark');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns light when light is preferred', async () => {
|
||||||
|
stubScheme('light');
|
||||||
|
const scope = effectScope();
|
||||||
|
let scheme: ReturnType<typeof usePreferredColorScheme>;
|
||||||
|
scope.run(() => {
|
||||||
|
scheme = usePreferredColorScheme();
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
expect(scheme!.value).toBe('light');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no-preference when neither matches', async () => {
|
||||||
|
stubScheme('none');
|
||||||
|
const scope = effectScope();
|
||||||
|
let scheme: ReturnType<typeof usePreferredColorScheme>;
|
||||||
|
scope.run(() => {
|
||||||
|
scheme = usePreferredColorScheme();
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
expect(scheme!.value).toBe('no-preference');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { computed } from 'vue';
|
||||||
|
import type { ComputedRef } from 'vue';
|
||||||
|
import type { ConfigurableWindow } from '@/types';
|
||||||
|
import { useMediaQuery } from '@/composables/browser/useMediaQuery';
|
||||||
|
|
||||||
|
export type ColorSchemePreference = 'dark' | 'light' | 'no-preference';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name usePreferredColorScheme
|
||||||
|
* @category Browser
|
||||||
|
* @description Reactive `prefers-color-scheme` media query.
|
||||||
|
*
|
||||||
|
* @param {ConfigurableWindow} [options={}] Options
|
||||||
|
* @returns {ComputedRef<ColorSchemePreference>} `'dark'`, `'light'`, or `'no-preference'`
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const scheme = usePreferredColorScheme();
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function usePreferredColorScheme(
|
||||||
|
options: ConfigurableWindow = {},
|
||||||
|
): ComputedRef<ColorSchemePreference> {
|
||||||
|
const isLight = useMediaQuery('(prefers-color-scheme: light)', options);
|
||||||
|
const isDark = useMediaQuery('(prefers-color-scheme: dark)', options);
|
||||||
|
|
||||||
|
return computed(() => {
|
||||||
|
if (isDark.value)
|
||||||
|
return 'dark';
|
||||||
|
|
||||||
|
if (isLight.value)
|
||||||
|
return 'light';
|
||||||
|
|
||||||
|
return 'no-preference';
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope, nextTick } from 'vue';
|
||||||
|
import { usePreferredDark } from '.';
|
||||||
|
|
||||||
|
describe(usePreferredDark, () => {
|
||||||
|
beforeEach(() => vi.stubGlobal('matchMedia', undefined));
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('reflects the prefers-color-scheme: dark query', async () => {
|
||||||
|
vi.stubGlobal('matchMedia', vi.fn((media: string) => ({
|
||||||
|
matches: media.includes('dark'),
|
||||||
|
media,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
})));
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let isDark: ReturnType<typeof usePreferredDark>;
|
||||||
|
scope.run(() => {
|
||||||
|
isDark = usePreferredDark();
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(isDark!.value).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Ref } from 'vue';
|
||||||
|
import type { ConfigurableWindow } from '@/types';
|
||||||
|
import { useMediaQuery } from '@/composables/browser/useMediaQuery';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name usePreferredDark
|
||||||
|
* @category Browser
|
||||||
|
* @description Reactive `prefers-color-scheme: dark` media query.
|
||||||
|
*
|
||||||
|
* @param {ConfigurableWindow} [options={}] Options
|
||||||
|
* @returns {Ref<boolean>} Whether the user prefers a dark color scheme
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const isDark = usePreferredDark();
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function usePreferredDark(options: ConfigurableWindow = {}): Ref<boolean> {
|
||||||
|
return useMediaQuery('(prefers-color-scheme: dark)', options);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { defineComponent, effectScope, nextTick } from 'vue';
|
import { defineComponent, effectScope, nextTick } from 'vue';
|
||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
import { useRafFn } from '.';
|
import { useRafFn } from '.';
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope, nextTick, ref } from 'vue';
|
||||||
|
import { useResizeObserver } from '.';
|
||||||
|
|
||||||
|
let instances: Array<{ cb: ResizeObserverCallback; observe: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn> }> = [];
|
||||||
|
|
||||||
|
class StubResizeObserver {
|
||||||
|
observe = vi.fn();
|
||||||
|
disconnect = vi.fn();
|
||||||
|
unobserve = vi.fn();
|
||||||
|
cb: ResizeObserverCallback;
|
||||||
|
constructor(cb: ResizeObserverCallback) {
|
||||||
|
this.cb = cb;
|
||||||
|
instances.push(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(useResizeObserver, () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
instances = [];
|
||||||
|
vi.stubGlobal('ResizeObserver', StubResizeObserver);
|
||||||
|
});
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('observes the target element', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useResizeObserver(ref(el), vi.fn()));
|
||||||
|
|
||||||
|
expect(instances).toHaveLength(1);
|
||||||
|
expect(instances[0]!.observe).toHaveBeenCalledWith(el, undefined);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the box option through to observe', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useResizeObserver(ref(el), vi.fn(), { box: 'border-box' }));
|
||||||
|
|
||||||
|
expect(instances[0]!.observe).toHaveBeenCalledWith(el, { box: 'border-box' });
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('observes an array of targets with a single observer', () => {
|
||||||
|
const a = document.createElement('div');
|
||||||
|
const b = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useResizeObserver([ref(a), ref(b)], vi.fn()));
|
||||||
|
|
||||||
|
expect(instances).toHaveLength(1);
|
||||||
|
expect(instances[0]!.observe).toHaveBeenCalledWith(a, undefined);
|
||||||
|
expect(instances[0]!.observe).toHaveBeenCalledWith(b, undefined);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports a getter target', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useResizeObserver(() => el, vi.fn()));
|
||||||
|
|
||||||
|
expect(instances).toHaveLength(1);
|
||||||
|
expect(instances[0]!.observe).toHaveBeenCalledWith(el, undefined);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disconnects on stop', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
let stop: () => void;
|
||||||
|
scope.run(() => {
|
||||||
|
stop = useResizeObserver(ref(el), vi.fn()).stop;
|
||||||
|
});
|
||||||
|
|
||||||
|
stop!();
|
||||||
|
expect(instances[0]!.disconnect).toHaveBeenCalled();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invokes the callback with entries', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const callback = vi.fn();
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useResizeObserver(ref(el), callback));
|
||||||
|
|
||||||
|
const entry = { contentRect: { width: 10, height: 20 } } as ResizeObserverEntry;
|
||||||
|
instances[0]!.cb([entry], instances[0] as unknown as ResizeObserver);
|
||||||
|
expect(callback).toHaveBeenCalledWith([entry], expect.anything());
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-observes when the target ref changes', async () => {
|
||||||
|
const a = document.createElement('div');
|
||||||
|
const b = document.createElement('div');
|
||||||
|
const target = ref<HTMLElement>(a);
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useResizeObserver(target, vi.fn()));
|
||||||
|
|
||||||
|
expect(instances).toHaveLength(1);
|
||||||
|
expect(instances[0]!.observe).toHaveBeenCalledWith(a, undefined);
|
||||||
|
|
||||||
|
target.value = b;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(instances[0]!.disconnect).toHaveBeenCalled();
|
||||||
|
expect(instances).toHaveLength(2);
|
||||||
|
expect(instances[1]!.observe).toHaveBeenCalledWith(b, undefined);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not create an observer for a null target', () => {
|
||||||
|
const target = ref<HTMLElement | null>(null);
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useResizeObserver(target, vi.fn()));
|
||||||
|
|
||||||
|
expect(instances).toHaveLength(0);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts observing when a null target is later assigned', async () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const target = ref<HTMLElement | null>(null);
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useResizeObserver(target, vi.fn()));
|
||||||
|
|
||||||
|
expect(instances).toHaveLength(0);
|
||||||
|
|
||||||
|
target.value = el;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(instances).toHaveLength(1);
|
||||||
|
expect(instances[0]!.observe).toHaveBeenCalledWith(el, undefined);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not observe when immediate is false until resumed', async () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
let controls!: ReturnType<typeof useResizeObserver>;
|
||||||
|
scope.run(() => {
|
||||||
|
controls = useResizeObserver(ref(el), vi.fn(), { immediate: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(controls.isActive.value).toBeFalsy();
|
||||||
|
expect(instances).toHaveLength(0);
|
||||||
|
|
||||||
|
controls.resume();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(controls.isActive.value).toBeTruthy();
|
||||||
|
expect(instances).toHaveLength(1);
|
||||||
|
expect(instances[0]!.observe).toHaveBeenCalledWith(el, undefined);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pause disconnects and flips isActive, resume re-observes', async () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
let controls!: ReturnType<typeof useResizeObserver>;
|
||||||
|
scope.run(() => {
|
||||||
|
controls = useResizeObserver(ref(el), vi.fn());
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(controls.isActive.value).toBeTruthy();
|
||||||
|
expect(instances).toHaveLength(1);
|
||||||
|
|
||||||
|
controls.pause();
|
||||||
|
expect(controls.isActive.value).toBeFalsy();
|
||||||
|
expect(instances[0]!.disconnect).toHaveBeenCalled();
|
||||||
|
|
||||||
|
controls.resume();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(controls.isActive.value).toBeTruthy();
|
||||||
|
expect(instances).toHaveLength(2);
|
||||||
|
expect(instances[1]!.observe).toHaveBeenCalledWith(el, undefined);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleans up when the scope is disposed', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useResizeObserver(ref(el), vi.fn()));
|
||||||
|
|
||||||
|
expect(instances).toHaveLength(1);
|
||||||
|
scope.stop();
|
||||||
|
expect(instances[0]!.disconnect).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { computed, readonly, ref, watch } from 'vue';
|
||||||
|
import type { Ref } from 'vue';
|
||||||
|
import { toArray } from '@robonen/stdlib';
|
||||||
|
import type { ConfigurableWindow } from '@/types';
|
||||||
|
import { defaultWindow } from '@/types';
|
||||||
|
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
||||||
|
import { unrefElement } from '@/composables/component/unrefElement';
|
||||||
|
import { useSupported } from '@/composables/browser/useSupported';
|
||||||
|
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||||
|
|
||||||
|
export interface UseResizeObserverOptions extends ConfigurableWindow {
|
||||||
|
/**
|
||||||
|
* The box model to observe
|
||||||
|
*
|
||||||
|
* @default 'content-box'
|
||||||
|
*/
|
||||||
|
box?: ResizeObserverBoxOptions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start observing immediately once the target is resolved
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
immediate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResizeObserverCallback = (
|
||||||
|
entries: readonly ResizeObserverEntry[],
|
||||||
|
observer: ResizeObserver,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
export interface UseResizeObserverReturn {
|
||||||
|
/**
|
||||||
|
* Whether `ResizeObserver` is supported in the current environment
|
||||||
|
*/
|
||||||
|
isSupported: Readonly<Ref<boolean>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the observer is currently active
|
||||||
|
*/
|
||||||
|
isActive: Readonly<Ref<boolean>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Temporarily stop observing (disconnects the observer) while keeping the
|
||||||
|
* target watcher alive, so observing can be resumed later
|
||||||
|
*/
|
||||||
|
pause: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume observing after a `pause`
|
||||||
|
*/
|
||||||
|
resume: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanently stop observing and tear down the target watcher
|
||||||
|
*/
|
||||||
|
stop: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useResizeObserver
|
||||||
|
* @category Browser
|
||||||
|
* @description Reports changes to the dimensions of an element via `ResizeObserver`.
|
||||||
|
* Accepts a single target or an array of (reactive) targets. The observer is
|
||||||
|
* recreated only when the resolved elements change, and can be paused/resumed.
|
||||||
|
*
|
||||||
|
* @param {MaybeComputedElementRef | MaybeComputedElementRef[]} target Element(s) to observe
|
||||||
|
* @param {ResizeObserverCallback} callback Invoked with the observer entries
|
||||||
|
* @param {UseResizeObserverOptions} [options={}] Options
|
||||||
|
* @returns {UseResizeObserverReturn} `isSupported`, `isActive`, `pause`, `resume`, and `stop`
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* useResizeObserver(el, ([entry]) => {
|
||||||
|
* console.log(entry.contentRect.width);
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { pause, resume } = useResizeObserver([el1, el2], (entries) => {
|
||||||
|
* // react to multiple targets
|
||||||
|
* }, { box: 'border-box' });
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useResizeObserver(
|
||||||
|
target: MaybeComputedElementRef | MaybeComputedElementRef[],
|
||||||
|
callback: ResizeObserverCallback,
|
||||||
|
options: UseResizeObserverOptions = {},
|
||||||
|
): UseResizeObserverReturn {
|
||||||
|
const { window = defaultWindow, box, immediate = true } = options;
|
||||||
|
|
||||||
|
const isSupported = useSupported(() => window && 'ResizeObserver' in window);
|
||||||
|
|
||||||
|
// Cache the observer options object so it is not rebuilt on every observe call
|
||||||
|
const observerOptions: ResizeObserverOptions | undefined = box ? { box } : undefined;
|
||||||
|
|
||||||
|
const isActive = ref(immediate);
|
||||||
|
|
||||||
|
let observer: ResizeObserver | undefined;
|
||||||
|
|
||||||
|
const targets = computed(() => {
|
||||||
|
return toArray(target).map(el => unrefElement(el)).filter((el): el is Element => Boolean(el));
|
||||||
|
});
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (observer) {
|
||||||
|
observer.disconnect();
|
||||||
|
observer = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopWatch = watch(
|
||||||
|
() => [targets.value, isActive.value] as const,
|
||||||
|
([els, active]) => {
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
if (!active || !isSupported.value || !window || !els.length)
|
||||||
|
return;
|
||||||
|
|
||||||
|
observer = new ResizeObserver(callback);
|
||||||
|
for (const el of els)
|
||||||
|
observer.observe(el, observerOptions);
|
||||||
|
},
|
||||||
|
{ immediate: true, flush: 'post' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const resume = () => {
|
||||||
|
isActive.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pause = () => {
|
||||||
|
cleanup();
|
||||||
|
isActive.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
cleanup();
|
||||||
|
stopWatch();
|
||||||
|
};
|
||||||
|
|
||||||
|
tryOnScopeDispose(stop);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSupported,
|
||||||
|
isActive: readonly(isActive),
|
||||||
|
pause,
|
||||||
|
resume,
|
||||||
|
stop,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope, nextTick } from 'vue';
|
||||||
|
import { useScreenOrientation } from '.';
|
||||||
|
import type { OrientationLockType, OrientationType } from '.';
|
||||||
|
|
||||||
|
type Listener = (event: Event) => void;
|
||||||
|
|
||||||
|
interface StubScreenOrientation {
|
||||||
|
type: OrientationType;
|
||||||
|
angle: number;
|
||||||
|
lock: ReturnType<typeof vi.fn>;
|
||||||
|
unlock: ReturnType<typeof vi.fn>;
|
||||||
|
addEventListener: (type: string, cb: Listener) => void;
|
||||||
|
removeEventListener: (type: string, cb: Listener) => void;
|
||||||
|
dispatch: (type: OrientationType, angle: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeScreenOrientation(
|
||||||
|
type: OrientationType = 'portrait-primary',
|
||||||
|
angle = 0,
|
||||||
|
): StubScreenOrientation {
|
||||||
|
const listeners = new Set<Listener>();
|
||||||
|
|
||||||
|
const so: StubScreenOrientation = {
|
||||||
|
type,
|
||||||
|
angle,
|
||||||
|
lock: vi.fn(() => Promise.resolve()),
|
||||||
|
unlock: vi.fn(),
|
||||||
|
addEventListener: (_: string, cb: Listener) => listeners.add(cb),
|
||||||
|
removeEventListener: (_: string, cb: Listener) => listeners.delete(cb),
|
||||||
|
dispatch(nextType: OrientationType, nextAngle: number) {
|
||||||
|
so.type = nextType;
|
||||||
|
so.angle = nextAngle;
|
||||||
|
for (const cb of listeners) cb(new Event('change'));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return so;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A window stub exposing `screen.orientation` + an event target for `orientationchange`. */
|
||||||
|
function makeWindowStub(so?: StubScreenOrientation): Window {
|
||||||
|
const winListeners = new Set<Listener>();
|
||||||
|
return {
|
||||||
|
screen: so ? { orientation: so } : {},
|
||||||
|
addEventListener: (_: string, cb: Listener) => winListeners.add(cb),
|
||||||
|
removeEventListener: (_: string, cb: Listener) => winListeners.delete(cb),
|
||||||
|
} as unknown as Window;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(useScreenOrientation, () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Force the SSR / unsupported branch unless a window is passed via options.
|
||||||
|
vi.stubGlobal('screen', undefined);
|
||||||
|
});
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('reports supported when screen.orientation is present', async () => {
|
||||||
|
const window = makeWindowStub(makeScreenOrientation());
|
||||||
|
const scope = effectScope();
|
||||||
|
let result: ReturnType<typeof useScreenOrientation>;
|
||||||
|
scope.run(() => {
|
||||||
|
result = useScreenOrientation({ window });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
expect(result!.isSupported.value).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reflects the initial orientation type and angle', async () => {
|
||||||
|
const so = makeScreenOrientation('landscape-primary', 90);
|
||||||
|
const window = makeWindowStub(so);
|
||||||
|
const scope = effectScope();
|
||||||
|
let result: ReturnType<typeof useScreenOrientation>;
|
||||||
|
scope.run(() => {
|
||||||
|
result = useScreenOrientation({ window });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
expect(result!.orientation.value).toBe('landscape-primary');
|
||||||
|
expect(result!.angle.value).toBe(90);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates on the screen.orientation change event', async () => {
|
||||||
|
const so = makeScreenOrientation('portrait-primary', 0);
|
||||||
|
const window = makeWindowStub(so);
|
||||||
|
const scope = effectScope();
|
||||||
|
let result: ReturnType<typeof useScreenOrientation>;
|
||||||
|
scope.run(() => {
|
||||||
|
result = useScreenOrientation({ window });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
expect(result!.orientation.value).toBe('portrait-primary');
|
||||||
|
|
||||||
|
so.dispatch('landscape-secondary', 270);
|
||||||
|
await nextTick();
|
||||||
|
expect(result!.orientation.value).toBe('landscape-secondary');
|
||||||
|
expect(result!.angle.value).toBe(270);
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lockOrientation delegates to screen.orientation.lock', async () => {
|
||||||
|
const so = makeScreenOrientation();
|
||||||
|
const window = makeWindowStub(so);
|
||||||
|
const scope = effectScope();
|
||||||
|
let result: ReturnType<typeof useScreenOrientation>;
|
||||||
|
scope.run(() => {
|
||||||
|
result = useScreenOrientation({ window });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const type: OrientationLockType = 'landscape';
|
||||||
|
await expect(result!.lockOrientation(type)).resolves.toBeUndefined();
|
||||||
|
expect(so.lock).toHaveBeenCalledWith('landscape');
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unlockOrientation delegates to screen.orientation.unlock', async () => {
|
||||||
|
const so = makeScreenOrientation();
|
||||||
|
const window = makeWindowStub(so);
|
||||||
|
const scope = effectScope();
|
||||||
|
let result: ReturnType<typeof useScreenOrientation>;
|
||||||
|
scope.run(() => {
|
||||||
|
result = useScreenOrientation({ window });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
result!.unlockOrientation();
|
||||||
|
expect(so.unlock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops updating after the scope is disposed', async () => {
|
||||||
|
const so = makeScreenOrientation('portrait-primary', 0);
|
||||||
|
const window = makeWindowStub(so);
|
||||||
|
const scope = effectScope();
|
||||||
|
let result: ReturnType<typeof useScreenOrientation>;
|
||||||
|
scope.run(() => {
|
||||||
|
result = useScreenOrientation({ window });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
|
||||||
|
so.dispatch('landscape-primary', 90);
|
||||||
|
await nextTick();
|
||||||
|
// Listener removed on dispose, so the value should not have changed.
|
||||||
|
expect(result!.orientation.value).toBe('portrait-primary');
|
||||||
|
expect(result!.angle.value).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is unsupported and safe when no window is available (SSR)', async () => {
|
||||||
|
// Pass an explicit falsy window: a destructuring default only fires for
|
||||||
|
// `undefined`, so `null` lets us exercise the genuine no-window branch.
|
||||||
|
const scope = effectScope();
|
||||||
|
let result: ReturnType<typeof useScreenOrientation>;
|
||||||
|
scope.run(() => {
|
||||||
|
result = useScreenOrientation({ window: null as unknown as Window });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(result!.isSupported.value).toBeFalsy();
|
||||||
|
expect(result!.orientation.value).toBeUndefined();
|
||||||
|
expect(result!.angle.value).toBe(0);
|
||||||
|
expect(() => result!.unlockOrientation()).not.toThrow();
|
||||||
|
await expect(result!.lockOrientation('any')).rejects.toThrow('Not supported');
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is unsupported when screen lacks orientation', async () => {
|
||||||
|
const window = makeWindowStub();
|
||||||
|
const scope = effectScope();
|
||||||
|
let result: ReturnType<typeof useScreenOrientation>;
|
||||||
|
scope.run(() => {
|
||||||
|
result = useScreenOrientation({ window });
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(result!.isSupported.value).toBeFalsy();
|
||||||
|
await expect(result!.lockOrientation('portrait')).rejects.toThrow('Not supported');
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { shallowRef } from 'vue';
|
||||||
|
import type { ComputedRef, ShallowRef } from 'vue';
|
||||||
|
import { isFunction } from '@robonen/stdlib';
|
||||||
|
import { defaultWindow } from '@/types';
|
||||||
|
import type { ConfigurableWindow } from '@/types';
|
||||||
|
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||||
|
import { useSupported } from '@/composables/browser/useSupported';
|
||||||
|
|
||||||
|
export type OrientationType
|
||||||
|
= | 'portrait-primary'
|
||||||
|
| 'portrait-secondary'
|
||||||
|
| 'landscape-primary'
|
||||||
|
| 'landscape-secondary';
|
||||||
|
|
||||||
|
export type OrientationLockType
|
||||||
|
= | 'any'
|
||||||
|
| 'natural'
|
||||||
|
| 'landscape'
|
||||||
|
| 'portrait'
|
||||||
|
| 'portrait-primary'
|
||||||
|
| 'portrait-secondary'
|
||||||
|
| 'landscape-primary'
|
||||||
|
| 'landscape-secondary';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subset of the `ScreenOrientation` interface that we interact with.
|
||||||
|
*/
|
||||||
|
export interface ScreenOrientation extends EventTarget {
|
||||||
|
readonly type: OrientationType;
|
||||||
|
readonly angle: number;
|
||||||
|
lock: (orientation: OrientationLockType) => Promise<void>;
|
||||||
|
unlock: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseScreenOrientationOptions extends ConfigurableWindow {}
|
||||||
|
|
||||||
|
export interface UseScreenOrientationReturn {
|
||||||
|
/**
|
||||||
|
* Whether the Screen Orientation API is supported
|
||||||
|
*/
|
||||||
|
isSupported: ComputedRef<boolean>;
|
||||||
|
/**
|
||||||
|
* Current screen orientation type, or `undefined` when unsupported
|
||||||
|
*/
|
||||||
|
orientation: ShallowRef<OrientationType | undefined>;
|
||||||
|
/**
|
||||||
|
* Current screen orientation angle in degrees (defaults to `0`)
|
||||||
|
*/
|
||||||
|
angle: ShallowRef<number>;
|
||||||
|
/**
|
||||||
|
* Lock the screen to the given orientation. Rejects when unsupported.
|
||||||
|
*/
|
||||||
|
lockOrientation: (type: OrientationLockType) => Promise<void>;
|
||||||
|
/**
|
||||||
|
* Release a previously applied orientation lock. No-op when unsupported.
|
||||||
|
*/
|
||||||
|
unlockOrientation: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useScreenOrientation
|
||||||
|
* @category Browser
|
||||||
|
* @description Reactive Screen Orientation API. Tracks the current orientation
|
||||||
|
* `type` and `angle`, and exposes helpers to lock/unlock the orientation. SSR-safe.
|
||||||
|
*
|
||||||
|
* @param {UseScreenOrientationOptions} [options={}] Options (custom `window`)
|
||||||
|
* @returns {UseScreenOrientationReturn} `{ isSupported, orientation, angle, lockOrientation, unlockOrientation }`
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { isSupported, orientation, angle, lockOrientation, unlockOrientation } = useScreenOrientation();
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Lock to landscape (must run from a user gesture / fullscreen context)
|
||||||
|
* const { lockOrientation } = useScreenOrientation();
|
||||||
|
* await lockOrientation('landscape');
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useScreenOrientation(options: UseScreenOrientationOptions = {}): UseScreenOrientationReturn {
|
||||||
|
const { window = defaultWindow } = options;
|
||||||
|
|
||||||
|
const isSupported = useSupported(() =>
|
||||||
|
Boolean(window && 'screen' in window && window.screen && 'orientation' in window.screen));
|
||||||
|
|
||||||
|
const screenOrientation = (isSupported.value ? window!.screen.orientation : {}) as ScreenOrientation;
|
||||||
|
|
||||||
|
const orientation = shallowRef<OrientationType | undefined>(screenOrientation.type);
|
||||||
|
const angle = shallowRef(screenOrientation.angle || 0);
|
||||||
|
|
||||||
|
const update = (): void => {
|
||||||
|
orientation.value = screenOrientation.type;
|
||||||
|
angle.value = screenOrientation.angle;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isSupported.value) {
|
||||||
|
// The standard `change` event fires on the `ScreenOrientation` object itself;
|
||||||
|
// `orientationchange` on `window` is the legacy fallback for older engines.
|
||||||
|
useEventListener(screenOrientation, 'change', update, { passive: true });
|
||||||
|
useEventListener(window, 'orientationchange', update, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const lockOrientation = (type: OrientationLockType): Promise<void> => {
|
||||||
|
if (isSupported.value && isFunction(screenOrientation.lock))
|
||||||
|
return screenOrientation.lock(type);
|
||||||
|
|
||||||
|
return Promise.reject(new Error('Not supported'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const unlockOrientation = (): void => {
|
||||||
|
if (isSupported.value && isFunction(screenOrientation.unlock))
|
||||||
|
screenOrientation.unlock();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSupported,
|
||||||
|
orientation,
|
||||||
|
angle,
|
||||||
|
lockOrientation,
|
||||||
|
unlockOrientation,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope, nextTick, ref } from 'vue';
|
||||||
|
import { useScroll } from '.';
|
||||||
|
|
||||||
|
function makeScrollable(overrides: Partial<{
|
||||||
|
scrollWidth: number;
|
||||||
|
scrollHeight: number;
|
||||||
|
clientWidth: number;
|
||||||
|
clientHeight: number;
|
||||||
|
}> = {}) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
Object.defineProperties(el, {
|
||||||
|
scrollWidth: { value: overrides.scrollWidth ?? 1000, configurable: true },
|
||||||
|
scrollHeight: { value: overrides.scrollHeight ?? 1000, configurable: true },
|
||||||
|
clientWidth: { value: overrides.clientWidth ?? 100, configurable: true },
|
||||||
|
clientHeight: { value: overrides.clientHeight ?? 100, configurable: true },
|
||||||
|
});
|
||||||
|
el.scrollLeft = 0;
|
||||||
|
el.scrollTop = 0;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withScope<T>(fn: () => T): { result: T; scope: ReturnType<typeof effectScope> } {
|
||||||
|
const scope = effectScope();
|
||||||
|
let result!: T;
|
||||||
|
scope.run(() => {
|
||||||
|
result = fn();
|
||||||
|
});
|
||||||
|
return { result, scope };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(useScroll, () => {
|
||||||
|
it('starts at the top-left with arrived state', () => {
|
||||||
|
const el = makeScrollable();
|
||||||
|
const { result, scope } = withScope(() => useScroll(ref(el)));
|
||||||
|
|
||||||
|
expect(result.x.value).toBe(0);
|
||||||
|
expect(result.y.value).toBe(0);
|
||||||
|
expect(result.arrivedState.top).toBeTruthy();
|
||||||
|
expect(result.arrivedState.left).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates position and isScrolling on scroll', async () => {
|
||||||
|
const el = makeScrollable();
|
||||||
|
const { result, scope } = withScope(() => useScroll(ref(el)));
|
||||||
|
|
||||||
|
el.scrollTop = 50;
|
||||||
|
el.scrollLeft = 20;
|
||||||
|
el.dispatchEvent(new Event('scroll'));
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(result.x.value).toBe(20);
|
||||||
|
expect(result.y.value).toBe(50);
|
||||||
|
expect(result.isScrolling.value).toBeTruthy();
|
||||||
|
expect(result.directions.bottom).toBeTruthy();
|
||||||
|
expect(result.directions.right).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags arrival at the bottom edge', async () => {
|
||||||
|
const el = makeScrollable();
|
||||||
|
const { result, scope } = withScope(() => useScroll(ref(el)));
|
||||||
|
|
||||||
|
el.scrollTop = 900; // 900 + 100 clientHeight >= 1000 scrollHeight
|
||||||
|
el.dispatchEvent(new Event('scroll'));
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(result.arrivedState.bottom).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('measures the initial scroll position on mount', () => {
|
||||||
|
const el = makeScrollable();
|
||||||
|
el.scrollLeft = 30;
|
||||||
|
el.scrollTop = 40;
|
||||||
|
const { result, scope } = withScope(() => useScroll(ref(el)));
|
||||||
|
|
||||||
|
expect(result.x.value).toBe(30);
|
||||||
|
expect(result.y.value).toBe(40);
|
||||||
|
expect(result.arrivedState.top).toBeFalsy();
|
||||||
|
expect(result.arrivedState.left).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes measure() to re-sync without a scroll event', () => {
|
||||||
|
const el = makeScrollable();
|
||||||
|
const { result, scope } = withScope(() => useScroll(ref(el)));
|
||||||
|
|
||||||
|
expect(result.y.value).toBe(0);
|
||||||
|
|
||||||
|
el.scrollTop = 200;
|
||||||
|
result.measure();
|
||||||
|
|
||||||
|
expect(result.y.value).toBe(200);
|
||||||
|
// measure() must not fabricate directions.
|
||||||
|
expect(result.directions.bottom).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects the offset when computing arrived state', async () => {
|
||||||
|
const el = makeScrollable();
|
||||||
|
const { result, scope } = withScope(() => useScroll(ref(el), { offset: { top: 10, bottom: 50 } }));
|
||||||
|
|
||||||
|
el.scrollTop = 8; // <= offset.top (10) => still arrived at top
|
||||||
|
el.dispatchEvent(new Event('scroll'));
|
||||||
|
await nextTick();
|
||||||
|
expect(result.arrivedState.top).toBeTruthy();
|
||||||
|
|
||||||
|
el.scrollTop = 855; // 855 + 100 >= 1000 - 50 - 1 => arrived at bottom early
|
||||||
|
el.dispatchEvent(new Event('scroll'));
|
||||||
|
await nextTick();
|
||||||
|
expect(result.arrivedState.bottom).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets isScrolling and directions and calls onStop after idle', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const onStop = vi.fn();
|
||||||
|
const el = makeScrollable();
|
||||||
|
const { result, scope } = withScope(() => useScroll(ref(el), { idle: 100, onStop }));
|
||||||
|
|
||||||
|
el.scrollTop = 50;
|
||||||
|
el.dispatchEvent(new Event('scroll'));
|
||||||
|
expect(result.isScrolling.value).toBeTruthy();
|
||||||
|
expect(result.directions.bottom).toBeTruthy();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(150);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(result.isScrolling.value).toBeFalsy();
|
||||||
|
expect(result.directions.bottom).toBeFalsy();
|
||||||
|
expect(onStop).toHaveBeenCalledTimes(1);
|
||||||
|
scope.stop();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalises a negative (RTL) scrollLeft for arrived state', async () => {
|
||||||
|
const el = makeScrollable();
|
||||||
|
const styleSpy = vi.spyOn(globalThis, 'getComputedStyle').mockReturnValue({ direction: 'rtl' } as CSSStyleDeclaration);
|
||||||
|
const { result, scope } = withScope(() => useScroll(ref(el)));
|
||||||
|
|
||||||
|
// RTL: scrolled to the far end reports a large negative magnitude.
|
||||||
|
el.scrollLeft = -900;
|
||||||
|
el.dispatchEvent(new Event('scroll'));
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
// |−900| + 100 clientWidth >= 1000 scrollWidth => arrived at the right edge.
|
||||||
|
expect(result.arrivedState.right).toBeTruthy();
|
||||||
|
expect(result.arrivedState.left).toBeFalsy();
|
||||||
|
styleSpy.mockRestore();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes scroll position through the x/y setters with behavior', () => {
|
||||||
|
const el = makeScrollable();
|
||||||
|
const scrollToSpy = vi.fn();
|
||||||
|
el.scrollTo = scrollToSpy as typeof el.scrollTo;
|
||||||
|
const { result, scope } = withScope(() => useScroll(ref(el), { behavior: 'smooth' }));
|
||||||
|
|
||||||
|
result.y.value = 120;
|
||||||
|
expect(scrollToSpy).toHaveBeenCalledWith({ top: 120, behavior: 'smooth' });
|
||||||
|
|
||||||
|
result.x.value = 60;
|
||||||
|
expect(scrollToSpy).toHaveBeenCalledWith({ left: 60, behavior: 'smooth' });
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invokes onError when reading metrics throws', () => {
|
||||||
|
const el = makeScrollable();
|
||||||
|
const onError = vi.fn();
|
||||||
|
const styleSpy = vi.spyOn(globalThis, 'getComputedStyle').mockImplementation(() => {
|
||||||
|
throw new Error('detached');
|
||||||
|
});
|
||||||
|
const { scope } = withScope(() => useScroll(ref(el), { onError }));
|
||||||
|
|
||||||
|
expect(onError).toHaveBeenCalled();
|
||||||
|
styleSpy.mockRestore();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when target is nullish', () => {
|
||||||
|
const { result, scope } = withScope(() => useScroll(ref(null)));
|
||||||
|
|
||||||
|
expect(result.x.value).toBe(0);
|
||||||
|
expect(result.y.value).toBe(0);
|
||||||
|
expect(() => result.measure()).not.toThrow();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throttles scroll updates when throttle is set', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const onScroll = vi.fn();
|
||||||
|
const el = makeScrollable();
|
||||||
|
const { scope } = withScope(() => useScroll(ref(el), { throttle: 100, onScroll }));
|
||||||
|
|
||||||
|
el.scrollTop = 10;
|
||||||
|
el.dispatchEvent(new Event('scroll'));
|
||||||
|
el.scrollTop = 20;
|
||||||
|
el.dispatchEvent(new Event('scroll'));
|
||||||
|
el.scrollTop = 30;
|
||||||
|
el.dispatchEvent(new Event('scroll'));
|
||||||
|
|
||||||
|
// Leading edge fires once immediately, the rest are throttled.
|
||||||
|
expect(onScroll).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(150);
|
||||||
|
// Trailing edge flushes the latest.
|
||||||
|
expect(onScroll).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import { computed, reactive, shallowRef, toValue } from 'vue';
|
||||||
|
import type { MaybeRefOrGetter, Ref } from 'vue';
|
||||||
|
import { noop } from '@robonen/stdlib';
|
||||||
|
import type { ConfigurableWindow } from '@/types';
|
||||||
|
import { defaultWindow } from '@/types';
|
||||||
|
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||||
|
import { useMutationObserver } from '@/composables/browser/useMutationObserver';
|
||||||
|
import { useThrottleFn } from '@/composables/utilities/useThrottleFn';
|
||||||
|
import { useDebounceFn } from '@/composables/utilities/useDebounceFn';
|
||||||
|
|
||||||
|
export interface UseScrollOffset {
|
||||||
|
left?: number;
|
||||||
|
right?: number;
|
||||||
|
top?: number;
|
||||||
|
bottom?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseScrollObserveOptions {
|
||||||
|
/**
|
||||||
|
* Re-measure the arrived/position state whenever the target's subtree
|
||||||
|
* mutates (children added/removed, attributes changed). Useful when the
|
||||||
|
* scrollable content grows or shrinks without a scroll event firing.
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
mutation?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseScrollOptions extends ConfigurableWindow {
|
||||||
|
/**
|
||||||
|
* Throttle delay (ms) for scroll position updates. `0` disables throttling.
|
||||||
|
*
|
||||||
|
* @default 0
|
||||||
|
*/
|
||||||
|
throttle?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idle time (ms) before `isScrolling` is reset to `false`
|
||||||
|
*
|
||||||
|
* @default 200
|
||||||
|
*/
|
||||||
|
idle?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distance (px) from each edge at which `arrivedState` flips to `true`
|
||||||
|
*
|
||||||
|
* @default { left: 0, right: 0, top: 0, bottom: 0 }
|
||||||
|
*/
|
||||||
|
offset?: UseScrollOffset;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called on every scroll event
|
||||||
|
*/
|
||||||
|
onScroll?: (event: Event) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when scrolling stops (after `idle`)
|
||||||
|
*/
|
||||||
|
onStop?: (event: Event) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listener options for the scroll event
|
||||||
|
*
|
||||||
|
* @default { capture: false, passive: true }
|
||||||
|
*/
|
||||||
|
eventListenerOptions?: boolean | AddEventListenerOptions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll behavior used when writing to `x`/`y`
|
||||||
|
*
|
||||||
|
* @default 'auto'
|
||||||
|
*/
|
||||||
|
behavior?: ScrollBehavior;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-measure the scroll state on DOM mutations of the target.
|
||||||
|
* Pass `true` to enable the default (`{ mutation: true }`).
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
observe?: boolean | UseScrollObserveOptions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error handler invoked when reading scroll metrics or computed style throws
|
||||||
|
* (e.g. a detached or cross-origin element).
|
||||||
|
*
|
||||||
|
* @default console.error
|
||||||
|
*/
|
||||||
|
onError?: (error: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseScrollTarget = MaybeRefOrGetter<HTMLElement | SVGElement | Window | Document | null | undefined>;
|
||||||
|
|
||||||
|
export interface UseScrollEdgeState {
|
||||||
|
left: boolean;
|
||||||
|
right: boolean;
|
||||||
|
top: boolean;
|
||||||
|
bottom: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseScrollReturn {
|
||||||
|
x: Ref<number>;
|
||||||
|
y: Ref<number>;
|
||||||
|
isScrolling: Ref<boolean>;
|
||||||
|
arrivedState: UseScrollEdgeState;
|
||||||
|
directions: UseScrollEdgeState;
|
||||||
|
/**
|
||||||
|
* Recompute `x`, `y`, `arrivedState`, and `directions` from the current DOM
|
||||||
|
* state. Call after a programmatic layout change that did not emit a scroll
|
||||||
|
* event.
|
||||||
|
*/
|
||||||
|
measure: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ARRIVED_STATE_THRESHOLD_PIXELS = 1;
|
||||||
|
|
||||||
|
interface ScrollMetrics {
|
||||||
|
scrollLeft: number;
|
||||||
|
scrollTop: number;
|
||||||
|
scrollWidth: number;
|
||||||
|
scrollHeight: number;
|
||||||
|
clientWidth: number;
|
||||||
|
clientHeight: number;
|
||||||
|
/**
|
||||||
|
* `-1` when the element is laid out right-to-left, `1` otherwise. Used to
|
||||||
|
* normalise the (possibly negative) `scrollLeft` reported under RTL.
|
||||||
|
*/
|
||||||
|
directionMultiplier: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWindow(value: unknown, window: Window | undefined): value is Window {
|
||||||
|
return value === window || (typeof Window !== 'undefined' && value instanceof Window);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScrollMetrics(
|
||||||
|
el: HTMLElement | SVGElement | Window | Document,
|
||||||
|
window: Window,
|
||||||
|
): ScrollMetrics {
|
||||||
|
if (isWindow(el, window)) {
|
||||||
|
const doc = window.document.documentElement;
|
||||||
|
return {
|
||||||
|
scrollLeft: window.scrollX,
|
||||||
|
scrollTop: window.scrollY,
|
||||||
|
scrollWidth: doc.scrollWidth,
|
||||||
|
scrollHeight: doc.scrollHeight,
|
||||||
|
clientWidth: window.innerWidth,
|
||||||
|
clientHeight: window.innerHeight,
|
||||||
|
directionMultiplier: getDirectionMultiplier(doc, window),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = (el instanceof Document ? el.documentElement : el) as HTMLElement;
|
||||||
|
|
||||||
|
return {
|
||||||
|
scrollLeft: node.scrollLeft,
|
||||||
|
scrollTop: node.scrollTop,
|
||||||
|
scrollWidth: node.scrollWidth,
|
||||||
|
scrollHeight: node.scrollHeight,
|
||||||
|
clientWidth: node.clientWidth,
|
||||||
|
clientHeight: node.clientHeight,
|
||||||
|
directionMultiplier: getDirectionMultiplier(node, window),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDirectionMultiplier(node: Element, window: Window): number {
|
||||||
|
// getComputedStyle can throw on detached nodes; callers wrap this in try/catch.
|
||||||
|
return window.getComputedStyle(node).direction === 'rtl' ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useScroll
|
||||||
|
* @category Browser
|
||||||
|
* @description Reactive scroll position and state for an element or the window,
|
||||||
|
* with arrived-edge detection (RTL-aware), scroll directions, an `isScrolling`
|
||||||
|
* flag, optional throttling, and a `measure()` method for manual re-sync.
|
||||||
|
*
|
||||||
|
* @param {UseScrollTarget} target The scroll container (can be reactive)
|
||||||
|
* @param {UseScrollOptions} [options={}] Options
|
||||||
|
* @returns {UseScrollReturn} Reactive position, scroll state, arrived edges, directions, and `measure`
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { x, y, isScrolling, arrivedState, measure } = useScroll(el);
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useScroll(
|
||||||
|
target: UseScrollTarget,
|
||||||
|
options: UseScrollOptions = {},
|
||||||
|
): UseScrollReturn {
|
||||||
|
const {
|
||||||
|
throttle = 0,
|
||||||
|
idle = 200,
|
||||||
|
onStop = noop,
|
||||||
|
onScroll = noop,
|
||||||
|
offset = {},
|
||||||
|
eventListenerOptions = { capture: false, passive: true },
|
||||||
|
behavior = 'auto',
|
||||||
|
window = defaultWindow,
|
||||||
|
observe: observeOption = false,
|
||||||
|
onError = noop,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const internalX = shallowRef(0);
|
||||||
|
const internalY = shallowRef(0);
|
||||||
|
|
||||||
|
const isScrolling = shallowRef(false);
|
||||||
|
const arrivedState = reactive<UseScrollEdgeState>({ left: true, right: false, top: true, bottom: false });
|
||||||
|
const directions = reactive<UseScrollEdgeState>({ left: false, right: false, top: false, bottom: false });
|
||||||
|
|
||||||
|
const scrollTo = (axis: 'x' | 'y', value: number): void => {
|
||||||
|
const el = toValue(target);
|
||||||
|
|
||||||
|
if (!el)
|
||||||
|
return;
|
||||||
|
|
||||||
|
(el instanceof Document ? el.documentElement : el as HTMLElement | Window).scrollTo(
|
||||||
|
axis === 'x' ? { left: value, behavior } : { top: value, behavior },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const x = computed<number>({
|
||||||
|
get: () => internalX.value,
|
||||||
|
set: value => scrollTo('x', value),
|
||||||
|
});
|
||||||
|
|
||||||
|
const y = computed<number>({
|
||||||
|
get: () => internalY.value,
|
||||||
|
set: value => scrollTo('y', value),
|
||||||
|
});
|
||||||
|
|
||||||
|
const setArrivedState = (m: ScrollMetrics): void => {
|
||||||
|
// RTL elements report a negative scrollLeft; normalise to a magnitude so
|
||||||
|
// edge maths is identical to the LTR case.
|
||||||
|
const left = Math.abs(m.scrollLeft);
|
||||||
|
const top = Math.abs(m.scrollTop);
|
||||||
|
|
||||||
|
arrivedState.left = left <= (offset.left ?? 0);
|
||||||
|
arrivedState.right = left + m.clientWidth >= m.scrollWidth - (offset.right ?? 0) - ARRIVED_STATE_THRESHOLD_PIXELS;
|
||||||
|
arrivedState.top = top <= (offset.top ?? 0);
|
||||||
|
arrivedState.bottom = top + m.clientHeight >= m.scrollHeight - (offset.bottom ?? 0) - ARRIVED_STATE_THRESHOLD_PIXELS;
|
||||||
|
};
|
||||||
|
|
||||||
|
// `trackDirections` only applies when driven by a real scroll event; a manual
|
||||||
|
// measure() should not invent directions, so it is skipped there.
|
||||||
|
const sync = (trackDirections: boolean): void => {
|
||||||
|
const el = toValue(target);
|
||||||
|
|
||||||
|
if (!el || !window)
|
||||||
|
return;
|
||||||
|
|
||||||
|
let m: ScrollMetrics;
|
||||||
|
try {
|
||||||
|
m = getScrollMetrics(el, window);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
onError(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const left = m.scrollLeft;
|
||||||
|
const top = m.scrollTop;
|
||||||
|
|
||||||
|
if (trackDirections) {
|
||||||
|
directions.left = left < internalX.value;
|
||||||
|
directions.right = left > internalX.value;
|
||||||
|
directions.top = top < internalY.value;
|
||||||
|
directions.bottom = top > internalY.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
setArrivedState(m);
|
||||||
|
|
||||||
|
internalX.value = left;
|
||||||
|
internalY.value = top;
|
||||||
|
};
|
||||||
|
|
||||||
|
const measure = (): void => sync(false);
|
||||||
|
|
||||||
|
const onScrollEnd = useDebounceFn((event: Event) => {
|
||||||
|
// Guard against the debounce trailing edge firing after we already settled.
|
||||||
|
if (!isScrolling.value)
|
||||||
|
return;
|
||||||
|
|
||||||
|
isScrolling.value = false;
|
||||||
|
directions.left = false;
|
||||||
|
directions.right = false;
|
||||||
|
directions.top = false;
|
||||||
|
directions.bottom = false;
|
||||||
|
onStop(event);
|
||||||
|
}, throttle + idle);
|
||||||
|
|
||||||
|
const onScrollHandler = (event: Event): void => {
|
||||||
|
if (!toValue(target) || !window)
|
||||||
|
return;
|
||||||
|
|
||||||
|
sync(true);
|
||||||
|
|
||||||
|
isScrolling.value = true;
|
||||||
|
onScrollEnd(event);
|
||||||
|
onScroll(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handler = throttle > 0
|
||||||
|
? useThrottleFn(onScrollHandler, throttle, true, true)
|
||||||
|
: onScrollHandler;
|
||||||
|
|
||||||
|
useEventListener(
|
||||||
|
target as MaybeRefOrGetter<EventTarget | null | undefined>,
|
||||||
|
'scroll',
|
||||||
|
handler as (event: Event) => void,
|
||||||
|
eventListenerOptions,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initial measure once a target is resolvable so x/y/arrivedState reflect the
|
||||||
|
// real starting position instead of the optimistic top-left defaults.
|
||||||
|
measure();
|
||||||
|
|
||||||
|
const observe = observeOption === true ? { mutation: true } : observeOption;
|
||||||
|
|
||||||
|
if (observe && observe.mutation) {
|
||||||
|
useMutationObserver(
|
||||||
|
// Window/Document are not observable elements; only observe real elements.
|
||||||
|
() => {
|
||||||
|
const el = toValue(target);
|
||||||
|
return el && !isWindow(el, window) && !(el instanceof Document) ? el : null;
|
||||||
|
},
|
||||||
|
() => measure(),
|
||||||
|
{ window, attributes: true, childList: true, subtree: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
isScrolling,
|
||||||
|
arrivedState,
|
||||||
|
directions,
|
||||||
|
measure,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope, isReadonly, nextTick, ref, shallowRef } from 'vue';
|
||||||
|
import { useScrollLock } from '.';
|
||||||
|
|
||||||
|
function makeIOSNavigator(): Navigator {
|
||||||
|
return {
|
||||||
|
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)',
|
||||||
|
platform: 'iPhone',
|
||||||
|
maxTouchPoints: 5,
|
||||||
|
} as unknown as Navigator;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDesktopNavigator(): Navigator {
|
||||||
|
return {
|
||||||
|
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
|
||||||
|
platform: 'MacIntel',
|
||||||
|
maxTouchPoints: 0,
|
||||||
|
} as unknown as Navigator;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(useScrollLock, () => {
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('returns a writable boolean ref (not readonly)', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
let isLocked: ReturnType<typeof useScrollLock>;
|
||||||
|
scope.run(() => {
|
||||||
|
isLocked = useScrollLock(ref(el));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(isLocked!.value).toBeFalsy();
|
||||||
|
expect(isReadonly(isLocked!)).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects the initialState argument and applies overflow when the element resolves', async () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
let isLocked: ReturnType<typeof useScrollLock>;
|
||||||
|
scope.run(() => {
|
||||||
|
isLocked = useScrollLock(ref(el), true);
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(isLocked!.value).toBeTruthy();
|
||||||
|
expect(el.style.overflow).toBe('hidden');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets overflow:hidden when locked via the setter', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
let isLocked: ReturnType<typeof useScrollLock>;
|
||||||
|
scope.run(() => {
|
||||||
|
isLocked = useScrollLock(ref(el), false, { navigator: makeDesktopNavigator() });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(el.style.overflow).toBe('');
|
||||||
|
|
||||||
|
isLocked!.value = true;
|
||||||
|
expect(el.style.overflow).toBe('hidden');
|
||||||
|
expect(isLocked!.value).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores the prior overflow value on unlock', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.style.overflow = 'auto';
|
||||||
|
const scope = effectScope();
|
||||||
|
let isLocked: ReturnType<typeof useScrollLock>;
|
||||||
|
scope.run(() => {
|
||||||
|
isLocked = useScrollLock(ref(el), false, { navigator: makeDesktopNavigator() });
|
||||||
|
});
|
||||||
|
|
||||||
|
isLocked!.value = true;
|
||||||
|
expect(el.style.overflow).toBe('hidden');
|
||||||
|
|
||||||
|
isLocked!.value = false;
|
||||||
|
expect(el.style.overflow).toBe('auto');
|
||||||
|
expect(isLocked!.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores an empty inline overflow when none was set before locking', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const scope = effectScope();
|
||||||
|
let isLocked: ReturnType<typeof useScrollLock>;
|
||||||
|
scope.run(() => {
|
||||||
|
isLocked = useScrollLock(ref(el), false, { navigator: makeDesktopNavigator() });
|
||||||
|
});
|
||||||
|
|
||||||
|
isLocked!.value = true;
|
||||||
|
expect(el.style.overflow).toBe('hidden');
|
||||||
|
|
||||||
|
isLocked!.value = false;
|
||||||
|
expect(el.style.overflow).toBe('');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lock is idempotent — setting true twice does not change state', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.style.overflow = 'scroll';
|
||||||
|
const scope = effectScope();
|
||||||
|
let isLocked: ReturnType<typeof useScrollLock>;
|
||||||
|
scope.run(() => {
|
||||||
|
isLocked = useScrollLock(ref(el), false, { navigator: makeDesktopNavigator() });
|
||||||
|
});
|
||||||
|
|
||||||
|
isLocked!.value = true;
|
||||||
|
isLocked!.value = true;
|
||||||
|
expect(el.style.overflow).toBe('hidden');
|
||||||
|
|
||||||
|
isLocked!.value = false;
|
||||||
|
// The original 'scroll' is preserved across the double-lock.
|
||||||
|
expect(el.style.overflow).toBe('scroll');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an element that already has overflow:hidden as locked', async () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.style.overflow = 'hidden';
|
||||||
|
const scope = effectScope();
|
||||||
|
let isLocked: ReturnType<typeof useScrollLock>;
|
||||||
|
scope.run(() => {
|
||||||
|
isLocked = useScrollLock(ref(el));
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(isLocked!.value).toBeTruthy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the lock once a lazily-resolved element appears', async () => {
|
||||||
|
const target = shallowRef<HTMLElement | null>(null);
|
||||||
|
const scope = effectScope();
|
||||||
|
let isLocked: ReturnType<typeof useScrollLock>;
|
||||||
|
scope.run(() => {
|
||||||
|
isLocked = useScrollLock(target);
|
||||||
|
});
|
||||||
|
|
||||||
|
isLocked!.value = true;
|
||||||
|
// No element yet, so nothing to lock.
|
||||||
|
expect(isLocked!.value).toBeFalsy();
|
||||||
|
|
||||||
|
const el = document.createElement('div');
|
||||||
|
target.value = el;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
// The watcher sees isLocked still false; lock again now that the el exists.
|
||||||
|
isLocked!.value = true;
|
||||||
|
expect(el.style.overflow).toBe('hidden');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when there is no element', () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
let isLocked: ReturnType<typeof useScrollLock>;
|
||||||
|
scope.run(() => {
|
||||||
|
isLocked = useScrollLock(ref(null));
|
||||||
|
});
|
||||||
|
|
||||||
|
isLocked!.value = true;
|
||||||
|
expect(isLocked!.value).toBeFalsy();
|
||||||
|
isLocked!.value = false;
|
||||||
|
expect(isLocked!.value).toBeFalsy();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers a passive:false touchmove listener on iOS when locking', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const addSpy = vi.spyOn(el, 'addEventListener');
|
||||||
|
const scope = effectScope();
|
||||||
|
let isLocked: ReturnType<typeof useScrollLock>;
|
||||||
|
scope.run(() => {
|
||||||
|
isLocked = useScrollLock(ref(el), false, { navigator: makeIOSNavigator() });
|
||||||
|
});
|
||||||
|
|
||||||
|
isLocked!.value = true;
|
||||||
|
|
||||||
|
expect(addSpy).toHaveBeenCalledWith(
|
||||||
|
'touchmove',
|
||||||
|
expect.any(Function),
|
||||||
|
expect.objectContaining({ passive: false }),
|
||||||
|
);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not register a touchmove listener on non-iOS platforms', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const addSpy = vi.spyOn(el, 'addEventListener');
|
||||||
|
const scope = effectScope();
|
||||||
|
let isLocked: ReturnType<typeof useScrollLock>;
|
||||||
|
scope.run(() => {
|
||||||
|
isLocked = useScrollLock(ref(el), false, { navigator: makeDesktopNavigator() });
|
||||||
|
});
|
||||||
|
|
||||||
|
isLocked!.value = true;
|
||||||
|
|
||||||
|
expect(addSpy).not.toHaveBeenCalledWith('touchmove', expect.any(Function), expect.anything());
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes the iOS touchmove listener on unlock', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const removeSpy = vi.spyOn(el, 'removeEventListener');
|
||||||
|
const scope = effectScope();
|
||||||
|
let isLocked: ReturnType<typeof useScrollLock>;
|
||||||
|
scope.run(() => {
|
||||||
|
isLocked = useScrollLock(ref(el), false, { navigator: makeIOSNavigator() });
|
||||||
|
});
|
||||||
|
|
||||||
|
isLocked!.value = true;
|
||||||
|
isLocked!.value = false;
|
||||||
|
|
||||||
|
expect(removeSpy).toHaveBeenCalledWith('touchmove', expect.any(Function), expect.objectContaining({ passive: false }));
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents default on a single-touch touchmove over a non-scrollable target (iOS)', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
document.body.appendChild(el);
|
||||||
|
let captured: EventListener | undefined;
|
||||||
|
vi.spyOn(el, 'addEventListener').mockImplementation((type, listener) => {
|
||||||
|
if (type === 'touchmove')
|
||||||
|
captured = listener as EventListener;
|
||||||
|
});
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let isLocked: ReturnType<typeof useScrollLock>;
|
||||||
|
scope.run(() => {
|
||||||
|
isLocked = useScrollLock(ref(el), false, { navigator: makeIOSNavigator() });
|
||||||
|
});
|
||||||
|
isLocked!.value = true;
|
||||||
|
|
||||||
|
expect(captured).toBeTypeOf('function');
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
target: el,
|
||||||
|
touches: [{}],
|
||||||
|
cancelable: true,
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
} as unknown as TouchEvent;
|
||||||
|
captured!(event);
|
||||||
|
|
||||||
|
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
document.body.removeChild(el);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT prevent default for multi-touch gestures (iOS pinch-zoom)', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
document.body.appendChild(el);
|
||||||
|
let captured: EventListener | undefined;
|
||||||
|
vi.spyOn(el, 'addEventListener').mockImplementation((type, listener) => {
|
||||||
|
if (type === 'touchmove')
|
||||||
|
captured = listener as EventListener;
|
||||||
|
});
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let isLocked: ReturnType<typeof useScrollLock>;
|
||||||
|
scope.run(() => {
|
||||||
|
isLocked = useScrollLock(ref(el), false, { navigator: makeIOSNavigator() });
|
||||||
|
});
|
||||||
|
isLocked!.value = true;
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
target: el,
|
||||||
|
touches: [{}, {}],
|
||||||
|
cancelable: true,
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
} as unknown as TouchEvent;
|
||||||
|
captured!(event);
|
||||||
|
|
||||||
|
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
document.body.removeChild(el);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT prevent default when the touch target is itself scrollable (iOS)', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
document.body.appendChild(el);
|
||||||
|
let captured: EventListener | undefined;
|
||||||
|
vi.spyOn(el, 'addEventListener').mockImplementation((type, listener) => {
|
||||||
|
if (type === 'touchmove')
|
||||||
|
captured = listener as EventListener;
|
||||||
|
});
|
||||||
|
|
||||||
|
const scrollable = document.createElement('div');
|
||||||
|
el.appendChild(scrollable);
|
||||||
|
|
||||||
|
// Force the inner element to look scrollable.
|
||||||
|
const win = {
|
||||||
|
getComputedStyle: (node: Element) =>
|
||||||
|
node === scrollable
|
||||||
|
? ({ overflowX: 'scroll', overflowY: 'scroll' } as CSSStyleDeclaration)
|
||||||
|
: ({ overflowX: 'visible', overflowY: 'visible' } as CSSStyleDeclaration),
|
||||||
|
} as unknown as Window;
|
||||||
|
|
||||||
|
const scope = effectScope();
|
||||||
|
let isLocked: ReturnType<typeof useScrollLock>;
|
||||||
|
scope.run(() => {
|
||||||
|
isLocked = useScrollLock(ref(el), false, { navigator: makeIOSNavigator(), window: win });
|
||||||
|
});
|
||||||
|
isLocked!.value = true;
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
target: scrollable,
|
||||||
|
touches: [{}],
|
||||||
|
cancelable: true,
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
} as unknown as TouchEvent;
|
||||||
|
captured!(event);
|
||||||
|
|
||||||
|
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
el.removeChild(scrollable);
|
||||||
|
document.body.removeChild(el);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores overflow and detaches the touchmove listener on scope dispose', () => {
|
||||||
|
// The composable registers tryOnScopeDispose(unlock), so disposing the scope
|
||||||
|
// restores the prior overflow and tears the iOS touchmove listener down even
|
||||||
|
// though the lock was triggered from the setter (outside the scope).
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.style.overflow = 'auto';
|
||||||
|
const removeSpy = vi.spyOn(el, 'removeEventListener');
|
||||||
|
const scope = effectScope();
|
||||||
|
let isLocked: ReturnType<typeof useScrollLock>;
|
||||||
|
scope.run(() => {
|
||||||
|
isLocked = useScrollLock(ref(el), false, { navigator: makeIOSNavigator() });
|
||||||
|
});
|
||||||
|
|
||||||
|
isLocked!.value = true;
|
||||||
|
expect(el.style.overflow).toBe('hidden');
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
|
||||||
|
expect(el.style.overflow).toBe('auto');
|
||||||
|
expect(removeSpy).toHaveBeenCalledWith('touchmove', expect.any(Function), expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not register listeners when window is falsy (SSR)', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const addSpy = vi.spyOn(el, 'addEventListener');
|
||||||
|
const scope = effectScope();
|
||||||
|
let isLocked: ReturnType<typeof useScrollLock>;
|
||||||
|
scope.run(() => {
|
||||||
|
isLocked = useScrollLock(ref(el), false, {
|
||||||
|
window: null as unknown as Window,
|
||||||
|
navigator: makeIOSNavigator(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
isLocked!.value = true;
|
||||||
|
|
||||||
|
// overflow is still toggled, but no iOS touchmove listener is attached.
|
||||||
|
expect(el.style.overflow).toBe('hidden');
|
||||||
|
expect(addSpy).not.toHaveBeenCalledWith('touchmove', expect.any(Function), expect.anything());
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import type { WritableComputedRef } from 'vue';
|
||||||
|
import { computed, shallowRef, toRef, watch } from 'vue';
|
||||||
|
import type { VoidFunction } from '@robonen/stdlib';
|
||||||
|
import type { ConfigurableNavigator, ConfigurableWindow } from '@/types';
|
||||||
|
import { defaultNavigator, defaultWindow } from '@/types';
|
||||||
|
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
||||||
|
import { unrefElement } from '@/composables/component/unrefElement';
|
||||||
|
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||||
|
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||||
|
|
||||||
|
type LockableElement = HTMLElement | SVGElement;
|
||||||
|
|
||||||
|
export interface UseScrollLockOptions extends ConfigurableWindow, ConfigurableNavigator {}
|
||||||
|
|
||||||
|
export type UseScrollLockReturn = WritableComputedRef<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores each element's `style.overflow` value as it was the first time we
|
||||||
|
* touched it, so unlocking restores exactly what the author had set (rather
|
||||||
|
* than wiping inline overflow entirely). A WeakMap keeps this from pinning
|
||||||
|
* detached elements in memory.
|
||||||
|
*/
|
||||||
|
const elementInitialOverflow = /* #__PURE__ */ new WeakMap<LockableElement, string>();
|
||||||
|
|
||||||
|
function isIOS(navigator: Navigator | undefined): boolean {
|
||||||
|
if (!navigator)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const ua = navigator.userAgent;
|
||||||
|
// iPhone / iPod / legacy iPad, plus iPadOS 13+ which masquerades as MacIntel
|
||||||
|
// while reporting a touch screen.
|
||||||
|
return /iP(?:ad|hone|od)/.test(ua)
|
||||||
|
|| (navigator.platform === 'MacIntel' && (navigator.maxTouchPoints ?? 0) > 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walks up from the touched node looking for an ancestor that can actually
|
||||||
|
* scroll. If one exists we must NOT prevent the touchmove, otherwise nested
|
||||||
|
* scroll regions (e.g. a modal body) become un-scrollable on iOS.
|
||||||
|
*/
|
||||||
|
function checkOverflowScroll(element: Element, window: Window): boolean {
|
||||||
|
const style = window.getComputedStyle(element);
|
||||||
|
|
||||||
|
if (
|
||||||
|
style.overflowX === 'scroll'
|
||||||
|
|| style.overflowY === 'scroll'
|
||||||
|
|| (style.overflowX === 'auto' && element.clientWidth < element.scrollWidth)
|
||||||
|
|| (style.overflowY === 'auto' && element.clientHeight < element.scrollHeight)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = element.parentNode as Element | null;
|
||||||
|
|
||||||
|
if (!parent || parent.tagName === 'BODY')
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return checkOverflowScroll(parent, window);
|
||||||
|
}
|
||||||
|
|
||||||
|
function preventDefault(event: TouchEvent, window: Window): void {
|
||||||
|
const target = event.target as Element | null;
|
||||||
|
|
||||||
|
// Allow scrolling inside genuinely scrollable descendants.
|
||||||
|
if (target && checkOverflowScroll(target, window))
|
||||||
|
return;
|
||||||
|
|
||||||
|
// A multi-touch gesture (pinch-zoom) should be left alone.
|
||||||
|
if (event.touches.length > 1)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (event.cancelable)
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useScrollLock
|
||||||
|
* @category Browser
|
||||||
|
* @description Lock scrolling of an element by toggling `overflow: hidden`,
|
||||||
|
* preserving the element's prior inline overflow and handling iOS `touchmove`.
|
||||||
|
* Returns a writable boolean ref — set it to lock/unlock, read it for state.
|
||||||
|
*
|
||||||
|
* @param {MaybeComputedElementRef} element - The element (or template ref / getter) to lock.
|
||||||
|
* @param {boolean} [initialState] - Whether the element starts locked. Defaults to `false`.
|
||||||
|
* @param {UseScrollLockOptions} [options] - Configurable `window` / `navigator` (mainly for SSR & testing).
|
||||||
|
* @returns {UseScrollLockReturn} A writable boolean ref; `true` while locked.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const el = useTemplateRef<HTMLElement>('el');
|
||||||
|
* const isLocked = useScrollLock(el);
|
||||||
|
* isLocked.value = true; // lock
|
||||||
|
* isLocked.value = false; // unlock
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useScrollLock(
|
||||||
|
element: MaybeComputedElementRef,
|
||||||
|
initialState = false,
|
||||||
|
options: UseScrollLockOptions = {},
|
||||||
|
): UseScrollLockReturn {
|
||||||
|
const { window = defaultWindow, navigator = defaultNavigator } = options;
|
||||||
|
|
||||||
|
const isLocked = shallowRef(initialState);
|
||||||
|
let stopTouchMoveListener: VoidFunction | null = null;
|
||||||
|
let initialOverflow = '';
|
||||||
|
|
||||||
|
watch(
|
||||||
|
toRef(element),
|
||||||
|
() => {
|
||||||
|
const el = unrefElement(element) as LockableElement | undefined;
|
||||||
|
|
||||||
|
if (!el)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const style = el.style;
|
||||||
|
|
||||||
|
if (!elementInitialOverflow.has(el))
|
||||||
|
elementInitialOverflow.set(el, style.overflow);
|
||||||
|
|
||||||
|
if (style.overflow !== 'hidden')
|
||||||
|
initialOverflow = style.overflow;
|
||||||
|
|
||||||
|
// The element was already hidden before we attached — treat as locked.
|
||||||
|
if (style.overflow === 'hidden') {
|
||||||
|
isLocked.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLocked.value)
|
||||||
|
style.overflow = 'hidden';
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const lock = (): void => {
|
||||||
|
const el = unrefElement(element) as LockableElement | undefined;
|
||||||
|
|
||||||
|
if (!el || isLocked.value)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (window && isIOS(navigator)) {
|
||||||
|
stopTouchMoveListener = useEventListener(
|
||||||
|
el as HTMLElement,
|
||||||
|
'touchmove',
|
||||||
|
(event: Event) => preventDefault(event as TouchEvent, window),
|
||||||
|
{ passive: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
el.style.overflow = 'hidden';
|
||||||
|
isLocked.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const unlock = (): void => {
|
||||||
|
const el = unrefElement(element) as LockableElement | undefined;
|
||||||
|
|
||||||
|
if (!el || !isLocked.value)
|
||||||
|
return;
|
||||||
|
|
||||||
|
stopTouchMoveListener?.();
|
||||||
|
stopTouchMoveListener = null;
|
||||||
|
|
||||||
|
el.style.overflow = initialOverflow;
|
||||||
|
elementInitialOverflow.delete(el);
|
||||||
|
isLocked.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Restore overflow and detach the iOS touchmove listener when the owning
|
||||||
|
// scope is disposed, regardless of where the lock was triggered from.
|
||||||
|
tryOnScopeDispose(unlock);
|
||||||
|
|
||||||
|
return computed<boolean>({
|
||||||
|
get() {
|
||||||
|
return isLocked.value;
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
if (value)
|
||||||
|
lock();
|
||||||
|
else
|
||||||
|
unlock();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope, ref } from 'vue';
|
||||||
|
import { useShare } from '.';
|
||||||
|
import type { UseShareOptions } from '.';
|
||||||
|
|
||||||
|
function stubShareNavigator(canShareResult = true) {
|
||||||
|
const share = vi.fn(async (_data?: UseShareOptions) => {});
|
||||||
|
const canShare = vi.fn((_data?: UseShareOptions) => canShareResult);
|
||||||
|
const navigator = { share, canShare } as unknown as Navigator;
|
||||||
|
return { navigator, share, canShare };
|
||||||
|
}
|
||||||
|
|
||||||
|
function withScope<T>(fn: () => T): { result: T; stop: () => void } {
|
||||||
|
const scope = effectScope();
|
||||||
|
let result!: T;
|
||||||
|
scope.run(() => {
|
||||||
|
result = fn();
|
||||||
|
});
|
||||||
|
return { result, stop: () => scope.stop() };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(useShare, () => {
|
||||||
|
it('reports supported when the Web Share API is present', () => {
|
||||||
|
const { navigator } = stubShareNavigator();
|
||||||
|
const { result, stop } = withScope(() => useShare({}, { navigator }));
|
||||||
|
expect(result.isSupported.value).toBeTruthy();
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports unsupported when canShare is missing', () => {
|
||||||
|
const navigator = {} as Navigator;
|
||||||
|
const { result, stop } = withScope(() => useShare({}, { navigator }));
|
||||||
|
expect(result.isSupported.value).toBeFalsy();
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls navigator.share with the default share options', async () => {
|
||||||
|
const { navigator, share, canShare } = stubShareNavigator();
|
||||||
|
const data = { title: 'Hello', text: 'World', url: 'https://example.com' };
|
||||||
|
const { result, stop } = withScope(() => useShare(data, { navigator }));
|
||||||
|
|
||||||
|
await result.share();
|
||||||
|
|
||||||
|
expect(canShare).toHaveBeenCalledWith(data);
|
||||||
|
expect(share).toHaveBeenCalledWith(data);
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges overrideData over the default options', async () => {
|
||||||
|
const { navigator, share } = stubShareNavigator();
|
||||||
|
const { result, stop } = withScope(() =>
|
||||||
|
useShare({ title: 'Default', text: 'Default text' }, { navigator }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await result.share({ text: 'Override text', url: 'https://override.dev' });
|
||||||
|
|
||||||
|
expect(share).toHaveBeenCalledWith({
|
||||||
|
title: 'Default',
|
||||||
|
text: 'Override text',
|
||||||
|
url: 'https://override.dev',
|
||||||
|
});
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves reactive/getter share options at call time', async () => {
|
||||||
|
const { navigator, share } = stubShareNavigator();
|
||||||
|
const title = ref('first');
|
||||||
|
const { result, stop } = withScope(() => useShare(() => ({ title: title.value }), { navigator }));
|
||||||
|
|
||||||
|
await result.share();
|
||||||
|
expect(share).toHaveBeenLastCalledWith({ title: 'first' });
|
||||||
|
|
||||||
|
title.value = 'second';
|
||||||
|
await result.share();
|
||||||
|
expect(share).toHaveBeenLastCalledWith({ title: 'second' });
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call share when canShare rejects the payload', async () => {
|
||||||
|
const { navigator, share, canShare } = stubShareNavigator(false);
|
||||||
|
const { result, stop } = withScope(() => useShare({ title: 'Nope' }, { navigator }));
|
||||||
|
|
||||||
|
await result.share();
|
||||||
|
|
||||||
|
expect(canShare).toHaveBeenCalled();
|
||||||
|
expect(share).not.toHaveBeenCalled();
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips canShare gating when canShare is unavailable', async () => {
|
||||||
|
const share = vi.fn(async () => {});
|
||||||
|
// No canShare -> isSupported is false, so share is a no-op.
|
||||||
|
const navigator = { share } as unknown as Navigator;
|
||||||
|
const { result, stop } = withScope(() => useShare({ title: 'Hi' }, { navigator }));
|
||||||
|
|
||||||
|
expect(result.isSupported.value).toBeFalsy();
|
||||||
|
await result.share();
|
||||||
|
expect(share).not.toHaveBeenCalled();
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when unsupported (SSR / missing navigator)', async () => {
|
||||||
|
const navigator = undefined as unknown as Navigator;
|
||||||
|
const { result, stop } = withScope(() => useShare({ title: 'Hi' }, { navigator }));
|
||||||
|
|
||||||
|
expect(result.isSupported.value).toBeFalsy();
|
||||||
|
await expect(result.share()).resolves.toBeUndefined();
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the share promise from navigator.share', async () => {
|
||||||
|
const { navigator, share } = stubShareNavigator();
|
||||||
|
share.mockResolvedValueOnce(undefined);
|
||||||
|
const { result, stop } = withScope(() => useShare({ title: 'Hi' }, { navigator }));
|
||||||
|
|
||||||
|
await expect(result.share()).resolves.toBeUndefined();
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { toValue } from 'vue';
|
||||||
|
import type { MaybeRefOrGetter, Ref } from 'vue';
|
||||||
|
import { defaultNavigator } from '@/types';
|
||||||
|
import type { ConfigurableNavigator } from '@/types';
|
||||||
|
import { useSupported } from '@/composables/browser/useSupported';
|
||||||
|
|
||||||
|
export interface UseShareOptions {
|
||||||
|
/**
|
||||||
|
* Title of the shared content
|
||||||
|
*/
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arbitrary text that forms the body of the message being shared
|
||||||
|
*/
|
||||||
|
text?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL string referring to a resource being shared
|
||||||
|
*/
|
||||||
|
url?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Array of `File` objects representing files to be shared
|
||||||
|
*/
|
||||||
|
files?: File[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subset of `Navigator` exposing the Web Share API surface, which is not yet in
|
||||||
|
* every lib DOM target.
|
||||||
|
*/
|
||||||
|
interface NavigatorWithShare {
|
||||||
|
share?: (data?: UseShareOptions) => Promise<void>;
|
||||||
|
canShare?: (data?: UseShareOptions) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseShareReturn {
|
||||||
|
/**
|
||||||
|
* Whether the Web Share API is available
|
||||||
|
*/
|
||||||
|
isSupported: Readonly<Ref<boolean>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoke the native share sheet, optionally merging `overrideData` over the
|
||||||
|
* default share options. Resolves once sharing finishes (or is skipped when
|
||||||
|
* unsupported / the payload cannot be shared).
|
||||||
|
*/
|
||||||
|
share: (overrideData?: MaybeRefOrGetter<UseShareOptions>) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useShare
|
||||||
|
* @category Browser
|
||||||
|
* @description Reactive Web Share API wrapper to invoke the native share sheet.
|
||||||
|
*
|
||||||
|
* @param {MaybeRefOrGetter<UseShareOptions>} [shareOptions={}] Default share payload (title, text, url, files)
|
||||||
|
* @param {UseShareOptions} [options={}] Options
|
||||||
|
* @returns {UseShareReturn} `isSupported` flag and a `share` method
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { share, isSupported } = useShare({ title: 'Hello', url: location.href });
|
||||||
|
* share();
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Override the default payload per call
|
||||||
|
* const { share } = useShare({ title: 'Default' });
|
||||||
|
* share({ text: 'One-off message' });
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useShare(
|
||||||
|
shareOptions: MaybeRefOrGetter<UseShareOptions> = {},
|
||||||
|
options: ConfigurableNavigator = {},
|
||||||
|
): UseShareReturn {
|
||||||
|
const { navigator = defaultNavigator } = options;
|
||||||
|
|
||||||
|
const _navigator = navigator as NavigatorWithShare | undefined;
|
||||||
|
const isSupported = useSupported(() => !!_navigator && 'canShare' in _navigator);
|
||||||
|
|
||||||
|
const share = async (overrideData: MaybeRefOrGetter<UseShareOptions> = {}): Promise<void> => {
|
||||||
|
if (!isSupported.value || !_navigator)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const data: UseShareOptions = {
|
||||||
|
...toValue(shareOptions),
|
||||||
|
...toValue(overrideData),
|
||||||
|
};
|
||||||
|
|
||||||
|
// `canShare` gates the payload (e.g. file types / size); only proceed when it
|
||||||
|
// accepts the data to avoid a guaranteed-to-reject `share()` call.
|
||||||
|
if (_navigator.canShare && !_navigator.canShare(data))
|
||||||
|
return;
|
||||||
|
|
||||||
|
return _navigator.share?.(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSupported,
|
||||||
|
share,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineComponent } from 'vue';
|
import { defineComponent } from 'vue';
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { useSupported } from '.';
|
import { useSupported } from '.';
|
||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,8 @@ export function useSupported(feature: () => unknown) {
|
|||||||
const isMounted = useMounted();
|
const isMounted = useMounted();
|
||||||
|
|
||||||
return computed(() => {
|
return computed(() => {
|
||||||
// add reactive dependency on isMounted
|
// Touch isMounted to register it as a reactive dependency
|
||||||
// eslint-disable-next-line no-unused-expressions
|
void isMounted.value;
|
||||||
isMounted.value;
|
|
||||||
|
|
||||||
return Boolean(feature());
|
return Boolean(feature());
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user