diff --git a/vue/toolkit/README.md b/vue/toolkit/README.md index 1878647..8c04cfd 100644 --- a/vue/toolkit/README.md +++ b/vue/toolkit/README.md @@ -1,6 +1,6 @@ # @robonen/vue -Collection of composables and utilities for Vue 3. +Collection of composables and utilities for Vue 3 — 100+ tree-shakeable, SSR-safe composables. ## Install @@ -10,19 +10,32 @@ pnpm install @robonen/vue ## Composables -| Category | Composables | -| -------------- | ------------------------------------------------------------------ | -| **browser** | `useEventListener`, `useFocusGuard`, `useSupported` | -| **component** | `unrefElement`, `useRenderCount`, `useRenderInfo` | +| Category | Composables | +| -------------- | ----------- | +| **browser** | `onKeyStroke`, `useActiveElement`, `useBodyScrollLock`, `useClickOutside`, `useClipboard`, `useCloseWatcher`, `useColorMode`, `useDevicePixelRatio`, `useDocumentReadyState`, `useDocumentVisibility`, `useDropZone`, `useElementBounding`, `useElementHover`, `useElementSize`, `useElementVisibility`, `useEscapeKey`, `useEventListener`, `useEyeDropper`, `useFavicon`, `useFileDialog`, `useFocus`, `useFocusGuard`, `useFocusWithin`, `useFps`, `useFullscreen`, `useGeolocation`, `useIdle`, `useIntersectionObserver`, `useIntervalFn`, `useKeyModifier`, `useMagicKeys`, `useMediaQuery`, `useMouse`, `useMousePressed`, `useMutationObserver`, `useNetwork`, `useObjectUrl`, `useOnline`, `usePageLeave`, `usePermission`, `usePointer`, `usePreferredColorScheme`, `usePreferredDark`, `useRafFn`, `useResizeObserver`, `useScreenOrientation`, `useScroll`, `useScrollLock`, `useShare`, `useSupported`, `useSwipe`, `useTabLeader`, `useTextSelection`, `useTitle`, `useVibrate`, `useWindowFocus`, `useWindowScroll`, `useWindowSize` | +| **component** | `unrefElement`, `useForwardExpose`, `useTemplateRefsList` | +| **debug** | `useRenderCount`, `useRenderInfo` | | **lifecycle** | `tryOnBeforeMount`, `tryOnMounted`, `tryOnScopeDispose`, `useMounted` | -| **math** | `useClamp` | -| **reactivity** | `broadcastedRef`, `useCached`, `useLastChanged`, `useSyncRefs` | -| **state** | `useAppSharedState`, `useAsyncState`, `useContextFactory`, `useCounter`, `useInjectionStore`, `useToggle` | +| **math** | `useClamp` | +| **reactivity** | `broadcastedRef`, `refAutoReset`, `refDebounced`, `refThrottled`, `until`, `useArrayFilter`, `useArrayFind`, `useArrayMap`, `useCached`, `useCloned`, `useCycleList`, `useLastChanged`, `usePrevious`, `useSyncRefs`, `useToNumber`, `useToString`, `watchDebounced`, `watchIgnorable`, `watchOnce`, `watchPausable`, `watchThrottled`, `whenever` | +| **state** | `useAppSharedState`, `useAsyncState`, `useContextFactory`, `useCounter`, `useId`, `useInjectionStore`, `useStepper`, `useToggle` | | **storage** | `useLocalStorage`, `useSessionStorage`, `useStorage`, `useStorageAsync` | -| **utilities** | `useOffsetPagination` | +| **utilities** | `useDebounceFn`, `useInterval`, `useOffsetPagination`, `useThrottleFn`, `useTimeoutFn`, `useTimestamp` | + +The package also exports event-filter helpers (`debounceFilter`, `throttleFilter`, `pausableFilter`, `createFilterWrapper`) and shared types (`ConfigurableWindow`, `ConfigurableDocument`, `ConfigurableNavigator`, `MaybeComputedElementRef`, …). ## Usage ```ts -import { useToggle, useEventListener } from '@robonen/vue'; -``` \ No newline at end of file +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(); +}); +``` diff --git a/vue/toolkit/eslint.config.ts b/vue/toolkit/eslint.config.ts new file mode 100644 index 0000000..c86da2b --- /dev/null +++ b/vue/toolkit/eslint.config.ts @@ -0,0 +1,3 @@ +import { base, compose, imports, stylistic, typescript, vitest, vue } from '@robonen/eslint'; + +export default compose(base, typescript, vue, vitest, imports, stylistic); diff --git a/vue/toolkit/oxlint.config.ts b/vue/toolkit/oxlint.config.ts deleted file mode 100644 index 2dcc41f..0000000 --- a/vue/toolkit/oxlint.config.ts +++ /dev/null @@ -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)); diff --git a/vue/toolkit/package.json b/vue/toolkit/package.json index d8b493b..205f643 100644 --- a/vue/toolkit/package.json +++ b/vue/toolkit/package.json @@ -37,19 +37,18 @@ } }, "scripts": { - "lint:check": "oxlint -c oxlint.config.ts", - "lint:fix": "oxlint -c oxlint.config.ts --fix", + "lint:check": "eslint .", + "lint:fix": "eslint . --fix", "test": "vitest run", "dev": "vitest dev", "build": "tsdown" }, "devDependencies": { - "@robonen/oxlint": "workspace:*", + "@robonen/eslint": "workspace:*", "@robonen/tsconfig": "workspace:*", "@robonen/tsdown": "workspace:*", - "@stylistic/eslint-plugin": "catalog:", "@vue/test-utils": "catalog:", - "oxlint": "catalog:", + "eslint": "catalog:", "tsdown": "catalog:" }, "dependencies": { diff --git a/vue/toolkit/src/composables/browser/index.ts b/vue/toolkit/src/composables/browser/index.ts index 3161ce6..f0c1ca3 100644 --- a/vue/toolkit/src/composables/browser/index.ts +++ b/vue/toolkit/src/composables/browser/index.ts @@ -1,7 +1,58 @@ +export * from './onKeyStroke'; +export * from './useActiveElement'; +export * from './useBodyScrollLock'; +export * from './useClickOutside'; +export * from './useClipboard'; +export * from './useCloseWatcher'; +export * from './useColorMode'; +export * from './useDevicePixelRatio'; +export * from './useDocumentReadyState'; +export * from './useDocumentVisibility'; +export * from './useDropZone'; +export * from './useElementBounding'; +export * from './useElementHover'; +export * from './useElementSize'; +export * from './useElementVisibility'; +export * from './useEscapeKey'; export * from './useEventListener'; +export * from './useEyeDropper'; +export * from './useFavicon'; +export * from './useFileDialog'; +export * from './useFocus'; export * from './useFocusGuard'; +export * from './useFocusWithin'; export * from './useFps'; +export * from './useFullscreen'; +export * from './useGeolocation'; +export * from './useIdle'; +export * from './useIntersectionObserver'; export * from './useIntervalFn'; +export * from './useKeyModifier'; +export * from './useMagicKeys'; +export * from './useMediaQuery'; +export * from './useMouse'; +export * from './useMousePressed'; +export * from './useMutationObserver'; +export * from './useNetwork'; +export * from './useObjectUrl'; +export * from './useOnline'; +export * from './usePageLeave'; +export * from './usePermission'; +export * from './usePointer'; +export * from './usePreferredColorScheme'; +export * from './usePreferredDark'; export * from './useRafFn'; +export * from './useResizeObserver'; +export * from './useScreenOrientation'; +export * from './useScroll'; +export * from './useScrollLock'; +export * from './useShare'; export * from './useSupported'; +export * from './useSwipe'; export * from './useTabLeader'; +export * from './useTextSelection'; +export * from './useTitle'; +export * from './useVibrate'; +export * from './useWindowFocus'; +export * from './useWindowScroll'; +export * from './useWindowSize'; diff --git a/vue/toolkit/src/composables/browser/onKeyStroke/index.test.ts b/vue/toolkit/src/composables/browser/onKeyStroke/index.test.ts new file mode 100644 index 0000000..589b97e --- /dev/null +++ b/vue/toolkit/src/composables/browser/onKeyStroke/index.test.ts @@ -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); + }); +}); diff --git a/vue/toolkit/src/composables/browser/onKeyStroke/index.ts b/vue/toolkit/src/composables/browser/onKeyStroke/index.ts new file mode 100644 index 0000000..18e63b2 --- /dev/null +++ b/vue/toolkit/src/composables/browser/onKeyStroke/index.ts @@ -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; + + /** + * 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; +} + +/** + * 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} [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 = {}): 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} [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 = {}): 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} [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 = {}): VoidFunction { + return onKeyStroke(key, handler, { ...options, eventName: 'keypress' }); +} diff --git a/vue/toolkit/src/composables/browser/useActiveElement/index.test.ts b/vue/toolkit/src/composables/browser/useActiveElement/index.test.ts new file mode 100644 index 0000000..2fbdd6e --- /dev/null +++ b/vue/toolkit/src/composables/browser/useActiveElement/index.test.ts @@ -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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useActiveElement/index.ts b/vue/toolkit/src/composables/browser/useActiveElement/index.ts new file mode 100644 index 0000000..ff16d2d --- /dev/null +++ b/vue/toolkit/src/composables/browser/useActiveElement/index.ts @@ -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 = ShallowRef; + +/** + * @name useActiveElement + * @category Browser + * @description Reactive `document.activeElement`, traversing open shadow roots. + * + * @param {UseActiveElementOptions} [options={}] Options + * @returns {UseActiveElementReturn} 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( + options: UseActiveElementOptions = {}, +): UseActiveElementReturn { + 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(); + + 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; +} diff --git a/vue/toolkit/src/composables/browser/useBodyScrollLock/index.test.ts b/vue/toolkit/src/composables/browser/useBodyScrollLock/index.test.ts new file mode 100644 index 0000000..f398901 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useBodyScrollLock/index.test.ts @@ -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(''); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useBodyScrollLock/index.ts b/vue/toolkit/src/composables/browser/useBodyScrollLock/index.ts new file mode 100644 index 0000000..1a4106a --- /dev/null +++ b/vue/toolkit/src/composables/browser/useBodyScrollLock/index.ts @@ -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(); +} diff --git a/vue/toolkit/src/composables/browser/useClickOutside/index.test.ts b/vue/toolkit/src/composables/browser/useClickOutside/index.test.ts new file mode 100644 index 0000000..1683971 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useClickOutside/index.test.ts @@ -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(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(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useClickOutside/index.ts b/vue/toolkit/src/composables/browser/useClickOutside/index.ts new file mode 100644 index 0000000..4a9a3cb --- /dev/null +++ b/vue/toolkit/src/composables/browser/useClickOutside/index.ts @@ -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>; + + /** + * 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 }); +} diff --git a/vue/toolkit/src/composables/browser/useClipboard/index.test.ts b/vue/toolkit/src/composables/browser/useClipboard/index.test.ts new file mode 100644 index 0000000..77e77c0 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useClipboard/index.test.ts @@ -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; + 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; + 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; + 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; + 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; + 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; + scope.run(() => { + clip = useClipboard({ navigator }); + }); + + let release: (v: string) => void = () => {}; + const promise = clip!.copy(() => new Promise((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; + scope.run(() => { + clip = useClipboard({ navigator }); + }); + + let releaseSlow: (v: string) => void = () => {}; + const slow = clip!.copy(() => new Promise((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; + 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; + 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(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useClipboard/index.ts b/vue/toolkit/src/composables/browser/useClipboard/index.ts new file mode 100644 index 0000000..54a564e --- /dev/null +++ b/vue/toolkit/src/composables/browser/useClipboard/index.ts @@ -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); + +export interface UseClipboardOptions 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 { + /** + * Whether the async Clipboard API is available + */ + isSupported: Readonly>; + + /** + * The current clipboard text (kept in sync when `read` is enabled) + */ + text: Readonly>; + + /** + * `true` for `copiedDuring` ms after a successful copy + */ + copied: Readonly>; + + /** + * `true` while an async `copy()` is in flight + */ + copyPending: Readonly>; + + /** + * Copy a value to the clipboard + */ + copy: Optional extends true ? (text?: ClipboardValue) => Promise : (text: ClipboardValue) => Promise; +} + +/** + * @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): UseClipboardReturn; +export function useClipboard(options: UseClipboardOptions>): UseClipboardReturn; +export function useClipboard( + options: UseClipboardOptions | undefined> = {}, +): UseClipboardReturn { + 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 { + 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 { + 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, + }; +} diff --git a/vue/toolkit/src/composables/browser/useCloseWatcher/index.test.ts b/vue/toolkit/src/composables/browser/useCloseWatcher/index.test.ts new file mode 100644 index 0000000..1c7583a --- /dev/null +++ b/vue/toolkit/src/composables/browser/useCloseWatcher/index.test.ts @@ -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 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + scope.run(() => { + cw = useCloseWatcher({ window: win }); + }); + + cw!.onClose(vi.fn()); + expect(instances[0]!.destroyed).toBeFalsy(); + + scope.stop(); + expect(instances[0]!.destroyed).toBeTruthy(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useCloseWatcher/index.ts b/vue/toolkit/src/composables/browser/useCloseWatcher/index.ts new file mode 100644 index 0000000..fa66ae5 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useCloseWatcher/index.ts @@ -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>; + + /** + * 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(); + 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, + }; +} diff --git a/vue/toolkit/src/composables/browser/useColorMode/index.test.ts b/vue/toolkit/src/composables/browser/useColorMode/index.test.ts new file mode 100644 index 0000000..5a184da --- /dev/null +++ b/vue/toolkit/src/composables/browser/useColorMode/index.test.ts @@ -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(); + 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 `` 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(); + + 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; + + 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; + + 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; + + 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; + + 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; + + 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; + 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; + + 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; + + 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; + + 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; + + 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>; + + 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; + + 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; + + 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(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useColorMode/index.ts b/vue/toolkit/src/composables/browser/useColorMode/index.ts new file mode 100644 index 0000000..d351b63 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useColorMode/index.ts @@ -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 extends UseStorageOptions, 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; + + /** + * 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>; + + /** + * 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; + + /** + * 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 + = WritableComputedRef & { + store: Ref; + system: ComputedRef; + state: ComputedRef; + }; + +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} [options={}] Options + * @returns {UseColorModeReturn} 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( + options: UseColorModeOptions = {}, +): UseColorModeReturn { + 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; + + const preferredDark = usePreferredDark({ window }); + const system = computed(() => preferredDark.value ? 'dark' : 'light'); + + const resolveStore = (): Ref => { + if (storageRef) + return storageRef; + + if (storageKey === null || storageKey === undefined) + return toRef(initialValue) as Ref; + + const backend = storage ?? window?.localStorage; + + if (!backend) + return toRef(initialValue) as Ref; + + return useStorage(storageKey, initialValue, backend, { + window, + listenToStorageChanges, + }); + }; + + const store = resolveStore(); + + const state = computed(() => + 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(); + const classesToRemove = new Set(); + 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(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({ + get() { + return emitAuto ? store.value : state.value; + }, + set(value) { + store.value = value; + }, + }); + + return Object.assign(mode, { store, system, state }) as UseColorModeReturn; +} diff --git a/vue/toolkit/src/composables/browser/useDevicePixelRatio/index.test.ts b/vue/toolkit/src/composables/browser/useDevicePixelRatio/index.test.ts new file mode 100644 index 0000000..ca58d97 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useDevicePixelRatio/index.test.ts @@ -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(); + 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(); + 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; + 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; + 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; + 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; + 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; + 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; + 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(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useDevicePixelRatio/index.ts b/vue/toolkit/src/composables/browser/useDevicePixelRatio/index.ts new file mode 100644 index 0000000..0aea64b --- /dev/null +++ b/vue/toolkit/src/composables/browser/useDevicePixelRatio/index.ts @@ -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>; + /** + * 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, + }; +} diff --git a/vue/toolkit/src/composables/browser/useDocumentReadyState/index.test.ts b/vue/toolkit/src/composables/browser/useDocumentReadyState/index.test.ts new file mode 100644 index 0000000..2973c45 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useDocumentReadyState/index.test.ts @@ -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; + 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; + 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; + 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; + 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; + 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; + 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(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useDocumentReadyState/index.ts b/vue/toolkit/src/composables/browser/useDocumentReadyState/index.ts new file mode 100644 index 0000000..acfe954 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useDocumentReadyState/index.ts @@ -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; + +/** + * @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(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; +} diff --git a/vue/toolkit/src/composables/browser/useDocumentVisibility/index.test.ts b/vue/toolkit/src/composables/browser/useDocumentVisibility/index.test.ts new file mode 100644 index 0000000..b629eda --- /dev/null +++ b/vue/toolkit/src/composables/browser/useDocumentVisibility/index.test.ts @@ -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; + scope.run(() => { + visibility = useDocumentVisibility(); + }); + + expect(visibility!.value).toBe('visible'); + scope.stop(); + }); + + it('updates on visibilitychange', async () => { + const scope = effectScope(); + let visibility: ReturnType; + 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; + 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; + 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; + 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; + 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(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useDocumentVisibility/index.ts b/vue/toolkit/src/composables/browser/useDocumentVisibility/index.ts new file mode 100644 index 0000000..9fd930f --- /dev/null +++ b/vue/toolkit/src/composables/browser/useDocumentVisibility/index.ts @@ -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; + +/** + * @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(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; +} diff --git a/vue/toolkit/src/composables/browser/useDropZone/index.test.ts b/vue/toolkit/src/composables/browser/useDropZone/index.test.ts new file mode 100644 index 0000000..a73120d --- /dev/null +++ b/vue/toolkit/src/composables/browser/useDropZone/index.test.ts @@ -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(['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(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(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useDropZone/index.ts b/vue/toolkit/src/composables/browser/useDropZone/index.ts new file mode 100644 index 0000000..b01dd1c --- /dev/null +++ b/vue/toolkit/src/composables/browser/useDropZone/index.ts @@ -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 | ((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; + /** + * The dropped files, or `null` when nothing has been dropped yet. + */ + files: ShallowRef; + /** + * Whether the Drag and Drop API is available in the current environment. + */ + isSupported: ComputedRef; +} + +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} 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('dropZone'); + * const { isOverDropZone, files } = useDropZone(dropZone, { + * dataTypes: ['image/png'], + * onDrop: (files) => console.log(files), + * }); + * + * @since 0.0.15 + */ +export function useDropZone( + target: MaybeComputedElementRef | MaybeRefOrGetter, + 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(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); + if (value instanceof Document) + return value; + return unrefElement(target as MaybeComputedElementRef); + }; + + useEventListener(resolveTarget, 'dragenter', event => handleDragEvent(event, 'enter')); + useEventListener(resolveTarget, 'dragover', event => handleDragEvent(event, 'over')); + useEventListener(resolveTarget, 'dragleave', event => handleDragEvent(event, 'leave')); + useEventListener(resolveTarget, 'drop', event => handleDragEvent(event, 'drop')); + + return { + isOverDropZone, + files, + isSupported, + }; +} diff --git a/vue/toolkit/src/composables/browser/useElementBounding/index.test.ts b/vue/toolkit/src/composables/browser/useElementBounding/index.test.ts new file mode 100644 index 0000000..121f4e8 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useElementBounding/index.test.ts @@ -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; + 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; + 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; + 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; + 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; + 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); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useElementBounding/index.ts b/vue/toolkit/src/composables/browser/useElementBounding/index.ts new file mode 100644 index 0000000..7fb2ce6 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useElementBounding/index.ts @@ -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; + width: Ref; + top: Ref; + right: Ref; + bottom: Ref; + left: Ref; + x: Ref; + y: Ref; + /** + * 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, + }; +} diff --git a/vue/toolkit/src/composables/browser/useElementHover/index.test.ts b/vue/toolkit/src/composables/browser/useElementHover/index.test.ts new file mode 100644 index 0000000..ce3f9bd --- /dev/null +++ b/vue/toolkit/src/composables/browser/useElementHover/index.test.ts @@ -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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useElementHover/index.ts b/vue/toolkit/src/composables/browser/useElementHover/index.ts new file mode 100644 index 0000000..7a546e9 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useElementHover/index.ts @@ -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; + +/** + * @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 | 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; +} diff --git a/vue/toolkit/src/composables/browser/useElementSize/index.test.ts b/vue/toolkit/src/composables/browser/useElementSize/index.test.ts new file mode 100644 index 0000000..140fcb2 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useElementSize/index.test.ts @@ -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; + disconnect: ReturnType; + unobserve: ReturnType; +} + +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 = {}) { + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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(document.createElement('div')); + const scope = effectScope(); + let size: ReturnType; + 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(document.createElement('div')); + const scope = effectScope(); + let size: ReturnType; + 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(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useElementSize/index.ts b/vue/toolkit/src/composables/browser/useElementSize/index.ts new file mode 100644 index 0000000..3568cc2 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useElementSize/index.ts @@ -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; + height: ShallowRef; + 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` 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 }; +} diff --git a/vue/toolkit/src/composables/browser/useElementVisibility/index.test.ts b/vue/toolkit/src/composables/browser/useElementVisibility/index.test.ts new file mode 100644 index 0000000..30e3d0d --- /dev/null +++ b/vue/toolkit/src/composables/browser/useElementVisibility/index.test.ts @@ -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; + 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; + 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; + 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; + 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; + 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; + 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(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useElementVisibility/index.ts b/vue/toolkit/src/composables/browser/useElementVisibility/index.ts new file mode 100644 index 0000000..4664482 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useElementVisibility/index.ts @@ -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 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; +} + +export type UseElementVisibilityReturn + = Controls extends true + ? UseElementVisibilityReturnWithControls + : ShallowRef; + +/** + * @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, +): UseElementVisibilityReturn; +export function useElementVisibility( + target: MaybeComputedElementRef, + options: UseElementVisibilityOptions, +): UseElementVisibilityReturn; +export function useElementVisibility( + target: MaybeComputedElementRef, + options: UseElementVisibilityOptions = {}, +): UseElementVisibilityReturn { + 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; +} diff --git a/vue/toolkit/src/composables/browser/useEscapeKey/index.test.ts b/vue/toolkit/src/composables/browser/useEscapeKey/index.test.ts new file mode 100644 index 0000000..7f267e7 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useEscapeKey/index.test.ts @@ -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(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useEscapeKey/index.ts b/vue/toolkit/src/composables/browser/useEscapeKey/index.ts new file mode 100644 index 0000000..394bf6b --- /dev/null +++ b/vue/toolkit/src/composables/browser/useEscapeKey/index.ts @@ -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; +} diff --git a/vue/toolkit/src/composables/browser/useEventListener/index.test.ts b/vue/toolkit/src/composables/browser/useEventListener/index.test.ts index e7c7f5e..9e4ac83 100644 --- a/vue/toolkit/src/composables/browser/useEventListener/index.test.ts +++ b/vue/toolkit/src/composables/browser/useEventListener/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { defineComponent, effectScope, nextTick, ref } from 'vue'; import { mount } from '@vue/test-utils'; import { useEventListener } from '.'; diff --git a/vue/toolkit/src/composables/browser/useEyeDropper/index.test.ts b/vue/toolkit/src/composables/browser/useEyeDropper/index.test.ts new file mode 100644 index 0000000..30a4286 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useEyeDropper/index.test.ts @@ -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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + scope.run(() => { + result = useEyeDropper({ window: win }); + }); + + await expect(result!.open()).rejects.toThrow(error); + expect(result!.sRGBHex.value).toBe(''); + scope.stop(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useEyeDropper/index.ts b/vue/toolkit/src/composables/browser/useEyeDropper/index.ts new file mode 100644 index 0000000..6d0a348 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useEyeDropper/index.ts @@ -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; + [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; + + /** + * The most recently picked color, in sRGB hexadecimal format + */ + sRGBHex: ShallowRef; + + /** + * 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; +} + +/** + * @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 { + 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, + }; +} diff --git a/vue/toolkit/src/composables/browser/useFavicon/index.test.ts b/vue/toolkit/src/composables/browser/useFavicon/index.test.ts new file mode 100644 index 0000000..bcbc2d5 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useFavicon/index.test.ts @@ -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('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; + scope.run(() => { + favicon = useFavicon('/a.png'); + }); + + favicon!.value = '/b.png'; + await nextTick(); + + const link = document.head.querySelector('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('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('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('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('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('link[rel*="icon"]'); + expect(link!.href).toContain('/light.png'); + + dark.value = true; + await nextTick(); + + link = document.head.querySelector('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; + 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('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('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; + 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(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useFavicon/index.ts b/vue/toolkit/src/composables/browser/useFavicon/index.ts new file mode 100644 index 0000000..050d026 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useFavicon/index.ts @@ -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 | Ref; + +// 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} [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, + options?: UseFaviconOptions, +): ComputedRef; +export function useFavicon( + newIcon?: MaybeRef, + options?: UseFaviconOptions, +): Ref; +export function useFavicon( + newIcon: MaybeRefOrGetter = 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(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; +} diff --git a/vue/toolkit/src/composables/browser/useFileDialog/index.test.ts b/vue/toolkit/src/composables/browser/useFileDialog/index.test.ts new file mode 100644 index 0000000..a528dbc --- /dev/null +++ b/vue/toolkit/src/composables/browser/useFileDialog/index.test.ts @@ -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)[index] = file; + }); + return list; +} + +function withScope(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(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useFileDialog/index.ts b/vue/toolkit/src/composables/browser/useFileDialog/index.ts new file mode 100644 index 0000000..73015a3 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useFileDialog/index.ts @@ -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; + + /** + * Comma-separated list of accepted file types (the input's `accept` attribute) + * + * @default '*' + */ + accept?: MaybeRefOrGetter; + + /** + * Hint for which camera/microphone to use on mobile capture (the input's `capture` attribute) + */ + capture?: MaybeRefOrGetter; + + /** + * Reset the selected files each time `open()` is called + * + * @default false + */ + reset?: MaybeRefOrGetter; + + /** + * Select directories instead of files (sets `webkitdirectory`) + * + * @default false + */ + directory?: MaybeRefOrGetter; + + /** + * Initial files to seed `files` with before any dialog is opened + */ + initialFiles?: File[] | FileList; + + /** + * Use a custom `` element instead of an internally created one + */ + input?: MaybeComputedElementRef; +} + +/** + * Subscribe to an event; returns an unsubscribe function. + */ +export type FileDialogEventHookOn = (callback: (param: T) => void) => { off: () => void }; + +export interface UseFileDialogReturn { + /** + * The currently selected files, or `null` when none are selected + */ + files: ComputedRef; + + /** + * Open the file dialog, optionally overriding options for this call only + */ + open: (localOptions?: Partial) => void; + + /** + * Clear the current selection + */ + reset: () => void; + + /** + * Register a callback fired when the selection changes + */ + onChange: FileDialogEventHookOn; + + /** + * 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 { + on: FileDialogEventHookOn; + trigger: (param: T) => void; +} + +function createEventHook(): EventHook { + const callbacks = new Set<(param: T) => void>(); + + const on: FileDialogEventHookOn = (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)[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(toFileList(options.initialFiles)); + + const { on: onChange, trigger: changeTrigger } = createEventHook(); + const { on: onCancel, trigger: cancelTrigger } = createEventHook(); + + const inputRef = shallowRef(); + + // 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): 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, + }; +} diff --git a/vue/toolkit/src/composables/browser/useFocus/index.test.ts b/vue/toolkit/src/composables/browser/useFocus/index.test.ts new file mode 100644 index 0000000..fd0dd5c --- /dev/null +++ b/vue/toolkit/src/composables/browser/useFocus/index.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest'; +import { effectScope, nextTick, ref } from 'vue'; +import { useFocus } from '.'; + +function host(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(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(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(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useFocus/index.ts b/vue/toolkit/src/composables/browser/useFocus/index.ts new file mode 100644 index 0000000..9374a00 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useFocus/index.ts @@ -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; +} + +/** + * @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('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({ + 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 }; +} diff --git a/vue/toolkit/src/composables/browser/useFocusGuard/index.test.ts b/vue/toolkit/src/composables/browser/useFocusGuard/index.test.ts index dd1afee..1215525 100644 --- a/vue/toolkit/src/composables/browser/useFocusGuard/index.test.ts +++ b/vue/toolkit/src/composables/browser/useFocusGuard/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, beforeEach, afterEach, expect } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { mount } from '@vue/test-utils'; import { defineComponent, nextTick } from 'vue'; import { useFocusGuard } from '.'; diff --git a/vue/toolkit/src/composables/browser/useFocusWithin/index.test.ts b/vue/toolkit/src/composables/browser/useFocusWithin/index.test.ts new file mode 100644 index 0000000..64dd15a --- /dev/null +++ b/vue/toolkit/src/composables/browser/useFocusWithin/index.test.ts @@ -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; + 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; + 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; + 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; + 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; + 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; + 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(container); + + const scope = effectScope(); + let result: ReturnType; + 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; + 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; + scope.run(() => { + result = useFocusWithin(container, { window: undefined }); + }); + + expect(result!.focused.value).toBeFalsy(); + + scope.stop(); + document.body.replaceChildren(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useFocusWithin/index.ts b/vue/toolkit/src/composables/browser/useFocusWithin/index.ts new file mode 100644 index 0000000..2be0080 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useFocusWithin/index.ts @@ -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; +} + +/** + * @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('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, + }; +} diff --git a/vue/toolkit/src/composables/browser/useFps/index.test.ts b/vue/toolkit/src/composables/browser/useFps/index.test.ts index d91d77b..3cf70d0 100644 --- a/vue/toolkit/src/composables/browser/useFps/index.test.ts +++ b/vue/toolkit/src/composables/browser/useFps/index.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { effectScope } from 'vue'; import { useFps } from '.'; diff --git a/vue/toolkit/src/composables/browser/useFullscreen/index.test.ts b/vue/toolkit/src/composables/browser/useFullscreen/index.test.ts new file mode 100644 index 0000000..08e4f5f --- /dev/null +++ b/vue/toolkit/src/composables/browser/useFullscreen/index.test.ts @@ -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; + addEventListener: ReturnType; + removeEventListener: ReturnType; +} + +interface FakeDoc { + documentElement: FakeEl; + exitFullscreen: ReturnType; + 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>(); + 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; + 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; + 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; + // 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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(null); + const document = createFakeDocument(); + const scope = effectScope(); + let fs: ReturnType; + 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; + 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; + scope.run(() => { + fs = useFullscreen(undefined, { document: document as unknown as Document }); + }); + + await fs!.enter(); + scope.stop(); + await Promise.resolve(); + expect(document.exitFullscreen).not.toHaveBeenCalled(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useFullscreen/index.ts b/vue/toolkit/src/composables/browser/useFullscreen/index.ts new file mode 100644 index 0000000..7830f7b --- /dev/null +++ b/vue/toolkit/src/composables/browser/useFullscreen/index.ts @@ -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; + /** + * Whether the target element is currently in fullscreen mode + */ + isFullscreen: ShallowRef; + /** + * Request fullscreen mode for the target element + */ + enter: () => Promise; + /** + * Exit fullscreen mode + */ + exit: () => Promise; + /** + * Toggle fullscreen mode for the target element + */ + toggle: () => Promise; +} + +// 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( + () => requestMethods.find(has), + ); + + const exitMethod = computed( + () => exitMethods.find(has), + ); + + const fullscreenFlag = computed( + () => 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 { + 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 { + 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 { + 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, + }; +} diff --git a/vue/toolkit/src/composables/browser/useGeolocation/index.test.ts b/vue/toolkit/src/composables/browser/useGeolocation/index.test.ts new file mode 100644 index 0000000..8845bd7 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useGeolocation/index.test.ts @@ -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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + scope.run(() => { + geo = useGeolocation({ navigator, immediate: false }); + }); + await expect(geo!.getCurrentPosition()).rejects.toThrow('not supported'); + scope.stop(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useGeolocation/index.ts b/vue/toolkit/src/composables/browser/useGeolocation/index.ts new file mode 100644 index 0000000..0bb631c --- /dev/null +++ b/vue/toolkit/src/composables/browser/useGeolocation/index.ts @@ -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; + + /** + * 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; + + /** + * 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; + + /** + * 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>; + + /** + * The most recent set of coordinates. + */ + coords: Readonly>>; + + /** + * The timestamp of the most recent position, or `null` before the first fix. + */ + locatedAt: Readonly>; + + /** + * The most recent error, or `null` if none. + */ + error: Readonly>; + + /** + * Whether at least one position fix has been received. + */ + ready: Readonly>; + + /** + * Whether the position is currently being watched. + */ + isActive: Readonly>; + + /** + * 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; +} + +const DEFAULT_COORDS: Omit = { + 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(null); + const error = shallowRef(null); + const coords = shallowRef>(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 { + 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, + }; +} diff --git a/vue/toolkit/src/composables/browser/useIdle/index.test.ts b/vue/toolkit/src/composables/browser/useIdle/index.test.ts new file mode 100644 index 0000000..9a7045f --- /dev/null +++ b/vue/toolkit/src/composables/browser/useIdle/index.test.ts @@ -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>(); + 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 & { hidden?: boolean }) { + const win = createTarget() as ReturnType & { 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(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useIdle/index.ts b/vue/toolkit/src/composables/browser/useIdle/index.ts new file mode 100644 index 0000000..8abf783 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useIdle/index.ts @@ -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; + + /** + * Timestamp (ms) of the last detected user activity + */ + lastActive: ShallowRef; + + /** + * Whether the idle tracker is currently running + */ + isPending: Readonly>; + + /** + * 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 | 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, + }; +} diff --git a/vue/toolkit/src/composables/browser/useIntersectionObserver/index.test.ts b/vue/toolkit/src/composables/browser/useIntersectionObserver/index.test.ts new file mode 100644 index 0000000..3289cd6 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useIntersectionObserver/index.test.ts @@ -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; + disconnect: ReturnType; +} + +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; + 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; + 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(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).IntersectionObserver; + const el = document.createElement('div'); + const scope = effectScope(); + let controls: ReturnType; + 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(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useIntersectionObserver/index.ts b/vue/toolkit/src/composables/browser/useIntersectionObserver/index.ts new file mode 100644 index 0000000..82ddd86 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useIntersectionObserver/index.ts @@ -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; + + /** + * Threshold(s) at which to trigger the callback. Reactive — pass a ref or + * getter to update it. + * + * @default 0 + */ + threshold?: MaybeRefOrGetter; + + /** + * Start observing immediately + * + * @default true + */ + immediate?: boolean; +} + +export interface UseIntersectionObserverReturn { + isSupported: Readonly>; + isActive: Readonly>; + 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} 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, + 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, + }; +} diff --git a/vue/toolkit/src/composables/browser/useIntervalFn/index.test.ts b/vue/toolkit/src/composables/browser/useIntervalFn/index.test.ts index 5abe7a9..7f64965 100644 --- a/vue/toolkit/src/composables/browser/useIntervalFn/index.test.ts +++ b/vue/toolkit/src/composables/browser/useIntervalFn/index.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { defineComponent, effectScope, nextTick, ref } from 'vue'; import { mount } from '@vue/test-utils'; import { useIntervalFn } from '.'; diff --git a/vue/toolkit/src/composables/browser/useKeyModifier/index.test.ts b/vue/toolkit/src/composables/browser/useKeyModifier/index.test.ts new file mode 100644 index 0000000..8568466 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useKeyModifier/index.test.ts @@ -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; + scope.run(() => { + state = useKeyModifier('Shift'); + }); + + expect(state!.value).toBeNull(); + scope.stop(); + }); + + it('respects a provided initial value', () => { + const scope = effectScope(); + let state: ReturnType>; + 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; + 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; + 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; + 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; + 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; + 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>(); + 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; + 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; + scope.run(() => { + state = useKeyModifier('Shift', { document: undefined, initial: false }); + }); + + expect(state!.value).toBeFalsy(); + scope.stop(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useKeyModifier/index.ts b/vue/toolkit/src/composables/browser/useKeyModifier/index.ts new file mode 100644 index 0000000..d44861f --- /dev/null +++ b/vue/toolkit/src/composables/browser/useKeyModifier/index.ts @@ -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 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 = ShallowRef; + +/** + * @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( + modifier: KeyModifier, + options: UseKeyModifierOptions = {}, +): UseKeyModifierReturn { + const { + events = DEFAULT_EVENTS, + document = defaultDocument, + initial = null, + } = options; + + const state = shallowRef(initial) as ShallowRef; + + if (document) { + useEventListener(document, events, (event: KeyboardEvent | MouseEvent) => { + if (isFunction(event.getModifierState)) + state.value = event.getModifierState(modifier); + }, { passive: true }); + } + + return state as UseKeyModifierReturn; +} diff --git a/vue/toolkit/src/composables/browser/useMagicKeys/index.test.ts b/vue/toolkit/src/composables/browser/useMagicKeys/index.test.ts new file mode 100644 index 0000000..885a4c1 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useMagicKeys/index.test.ts @@ -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(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useMagicKeys/index.ts b/vue/toolkit/src/composables/browser/useMagicKeys/index.ts new file mode 100644 index 0000000..df1a5e6 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useMagicKeys/index.ts @@ -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>; + +/** + * 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 { + /** + * 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; + + /** + * Reset all tracked keys to `false` and clear the current Set + */ + reset: () => void; +} + +export type MagicKeys = Readonly< + Omit< + Record>, + keyof UseMagicKeysReturn + > + & UseMagicKeysReturn +>; + +type KeyRefs = Record | ShallowRef | ComputedRef>; + +/** + * @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): MagicKeys; +export function useMagicKeys(options: UseMagicKeysOptions): MagicKeys; +export function useMagicKeys(options: UseMagicKeysOptions = {}): any { + const { + reactive: useReactive = false, + target = defaultWindow, + aliasMap = DefaultMagicKeysAliasMap, + passive = true, + onEventFired = noop as AnyFunction, + } = options; + + const current = reactive(new Set()); + const usedKeys = new Set(); + // 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(); + + 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).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; +} diff --git a/vue/toolkit/src/composables/browser/useMediaQuery/index.test.ts b/vue/toolkit/src/composables/browser/useMediaQuery/index.test.ts new file mode 100644 index 0000000..7f887ca --- /dev/null +++ b/vue/toolkit/src/composables/browser/useMediaQuery/index.test.ts @@ -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(); + 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, 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; + 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; + 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; + 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; + 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; + 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; + 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; + let narrow: ReturnType; + 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; + 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; + let outOfRange: ReturnType; + 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; + 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; + 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; + // 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; + 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; + 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(); + }); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useMediaQuery/index.ts b/vue/toolkit/src/composables/browser/useMediaQuery/index.ts new file mode 100644 index 0000000..1c87599 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useMediaQuery/index.ts @@ -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} query The media query (can be reactive) + * @param {UseMediaQueryOptions} [options={}] Options (custom `window`, `ssrWidth`) + * @returns {ComputedRef} 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, + options: UseMediaQueryOptions = {}, +): ComputedRef { + const { window = defaultWindow, ssrWidth } = options; + + const isSupported = useSupported(() => + window && 'matchMedia' in window && isFunction(window.matchMedia)); + + const ssrSupport = shallowRef(isNumber(ssrWidth)); + + const mediaQuery = shallowRef(); + 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); +} diff --git a/vue/toolkit/src/composables/browser/useMouse/index.test.ts b/vue/toolkit/src/composables/browser/useMouse/index.test.ts new file mode 100644 index 0000000..8b2e2dd --- /dev/null +++ b/vue/toolkit/src/composables/browser/useMouse/index.test.ts @@ -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>) { + 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; + 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; + 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; + 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; + 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; + 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; + // 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useMouse/index.ts b/vue/toolkit/src/composables/browser/useMouse/index.ts new file mode 100644 index 0000000..7017f72 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useMouse/index.ts @@ -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; + y: Ref; + sourceType: Ref; +} + +const builtinExtractors: Record = { + 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(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; +} diff --git a/vue/toolkit/src/composables/browser/useMousePressed/index.test.ts b/vue/toolkit/src/composables/browser/useMousePressed/index.test.ts new file mode 100644 index 0000000..6076c2e --- /dev/null +++ b/vue/toolkit/src/composables/browser/useMousePressed/index.test.ts @@ -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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useMousePressed/index.ts b/vue/toolkit/src/composables/browser/useMousePressed/index.ts new file mode 100644 index 0000000..4d75f87 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useMousePressed/index.ts @@ -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; + sourceType: ShallowRef; +} + +/** + * @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(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 = 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 }; +} diff --git a/vue/toolkit/src/composables/browser/useMutationObserver/index.test.ts b/vue/toolkit/src/composables/browser/useMutationObserver/index.test.ts new file mode 100644 index 0000000..cc6e21e --- /dev/null +++ b/vue/toolkit/src/composables/browser/useMutationObserver/index.test.ts @@ -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; disconnect: ReturnType; takeRecords: ReturnType }> = []; + +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(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; + 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; + 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; + 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; + 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(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useMutationObserver/index.ts b/vue/toolkit/src/composables/browser/useMutationObserver/index.ts new file mode 100644 index 0000000..c5ef56f --- /dev/null +++ b/vue/toolkit/src/composables/browser/useMutationObserver/index.ts @@ -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>; + /** + * Whether the observer is currently active (not paused or stopped) + */ + isActive: Readonly>; + /** + * 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} 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, + 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(); + + 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, + }; +} diff --git a/vue/toolkit/src/composables/browser/useNetwork/index.test.ts b/vue/toolkit/src/composables/browser/useNetwork/index.test.ts new file mode 100644 index 0000000..b90deb2 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useNetwork/index.test.ts @@ -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; + 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; + 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; + 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; + 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; + 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 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; + 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; + 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(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useNetwork/index.ts b/vue/toolkit/src/composables/browser/useNetwork/index.ts new file mode 100644 index 0000000..ba303bb --- /dev/null +++ b/vue/toolkit/src/composables/browser/useNetwork/index.ts @@ -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>; + /** + * Whether the browser is currently online (`navigator.onLine`). + */ + isOnline: Readonly>; + /** + * The timestamp of the last time the browser went offline, in ms. + */ + offlineAt: Readonly>; + /** + * The timestamp of the last time the browser came back online, in ms. + */ + onlineAt: Readonly>; + /** + * The estimated effective bandwidth in megabits per second. + */ + downlink: Readonly>; + /** + * The maximum downlink speed of the underlying connection technology, in Mbps. + */ + downlinkMax: Readonly>; + /** + * The effective type of the connection (`slow-2g`, `2g`, `3g`, or `4g`). + */ + effectiveType: Readonly>; + /** + * The estimated effective round-trip time of the current connection, in ms. + */ + rtt: Readonly>; + /** + * Whether the user has requested a reduced data usage mode. + */ + saveData: Readonly>; + /** + * The type of connection a device is using to communicate with the network. + */ + type: Readonly>; +} + +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(undefined); + const offlineAt = shallowRef(undefined); + const onlineAt = shallowRef(undefined); + const downlink = shallowRef(undefined); + const downlinkMax = shallowRef(undefined); + const rtt = shallowRef(undefined); + const effectiveType = shallowRef(undefined); + const type = shallowRef('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), + }; +} diff --git a/vue/toolkit/src/composables/browser/useObjectUrl/index.test.ts b/vue/toolkit/src/composables/browser/useObjectUrl/index.test.ts new file mode 100644 index 0000000..a362535 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useObjectUrl/index.test.ts @@ -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; + 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; + scope.run(() => { + url = useObjectUrl(shallowRef(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(makeBlob('a')); + const scope = effectScope(); + + let url: ReturnType; + 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(makeBlob()); + const scope = effectScope(); + + let url: ReturnType; + 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; + 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(undefined); + const scope = effectScope(); + + let url: ReturnType; + 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; + 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; + 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(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useObjectUrl/index.ts b/vue/toolkit/src/composables/browser/useObjectUrl/index.ts new file mode 100644 index 0000000..492aae2 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useObjectUrl/index.ts @@ -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>; + +/** + * @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} 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(); + * const url = useObjectUrl(file); + * + * @since 0.0.15 + */ +export function useObjectUrl( + object: MaybeRefOrGetter, + options: UseObjectUrlOptions = {}, +): UseObjectUrlReturn { + const { window = defaultWindow } = options; + + const url = shallowRef(); + + 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); +} diff --git a/vue/toolkit/src/composables/browser/useOnline/index.test.ts b/vue/toolkit/src/composables/browser/useOnline/index.test.ts new file mode 100644 index 0000000..64ad7c3 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useOnline/index.test.ts @@ -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; + 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; + 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; + scope.run(() => { + online = useOnline({ window: undefined as any }); + }); + expect(online!.value).toBeTruthy(); + scope.stop(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useOnline/index.ts b/vue/toolkit/src/composables/browser/useOnline/index.ts new file mode 100644 index 0000000..0177e68 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useOnline/index.ts @@ -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>} Whether the browser is online + * + * @example + * const online = useOnline(); + * + * @since 0.0.15 + */ +export function useOnline(options: UseOnlineOptions = {}): Readonly> { + 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); +} diff --git a/vue/toolkit/src/composables/browser/usePageLeave/index.test.ts b/vue/toolkit/src/composables/browser/usePageLeave/index.test.ts new file mode 100644 index 0000000..131a468 --- /dev/null +++ b/vue/toolkit/src/composables/browser/usePageLeave/index.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from 'vitest'; +import { effectScope, isReadonly, nextTick } from 'vue'; +import { usePageLeave } from '.'; + +function withScope(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(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/usePageLeave/index.ts b/vue/toolkit/src/composables/browser/usePageLeave/index.ts new file mode 100644 index 0000000..fe3f837 --- /dev/null +++ b/vue/toolkit/src/composables/browser/usePageLeave/index.ts @@ -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; + +/** + * @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; +} diff --git a/vue/toolkit/src/composables/browser/usePermission/index.test.ts b/vue/toolkit/src/composables/browser/usePermission/index.test.ts new file mode 100644 index 0000000..43bc094 --- /dev/null +++ b/vue/toolkit/src/composables/browser/usePermission/index.test.ts @@ -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(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/usePermission/index.ts b/vue/toolkit/src/composables/browser/usePermission/index.ts new file mode 100644 index 0000000..eda344a --- /dev/null +++ b/vue/toolkit/src/composables/browser/usePermission/index.ts @@ -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 extends ConfigurableNavigator { + /** + * Expose the `isSupported` flag and a `query` method that returns the raw `PermissionStatus` + * + * @default false + */ + controls?: Controls; +} + +export type UsePermissionReturn = Readonly>; + +export interface UsePermissionReturnWithControls { + /** + * Reactive permission state (`granted` | `denied` | `prompt`), or `undefined` while unsupported/unresolved + */ + state: UsePermissionReturn; + /** + * Whether the Permissions API is available + */ + isSupported: Readonly>; + /** + * Query (or re-query) the permission, resolving to the raw `PermissionStatus` + */ + query: () => Promise; +} + +/** + * @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, +): UsePermissionReturn; +export function usePermission( + permissionDesc: GeneralPermissionDescriptor | GeneralPermissionDescriptor['name'], + options: UsePermissionOptions, +): UsePermissionReturnWithControls; +export function usePermission( + permissionDesc: GeneralPermissionDescriptor | GeneralPermissionDescriptor['name'], + options: UsePermissionOptions = {}, +): 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 = shallowRef(); + const state: ShallowRef = 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 | undefined; + + const query = (): Promise => { + 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; +} diff --git a/vue/toolkit/src/composables/browser/usePointer/index.test.ts b/vue/toolkit/src/composables/browser/usePointer/index.test.ts new file mode 100644 index 0000000..3f0d198 --- /dev/null +++ b/vue/toolkit/src/composables/browser/usePointer/index.test.ts @@ -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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/usePointer/index.ts b/vue/toolkit/src/composables/browser/usePointer/index.ts new file mode 100644 index 0000000..c39d372 --- /dev/null +++ b/vue/toolkit/src/composables/browser/usePointer/index.ts @@ -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; + + /** + * 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; + y: WritableComputedRef; + pressure: WritableComputedRef; + pointerId: WritableComputedRef; + tiltX: WritableComputedRef; + tiltY: WritableComputedRef; + width: WritableComputedRef; + height: WritableComputedRef; + twist: WritableComputedRef; + pointerType: WritableComputedRef; + isInside: ShallowRef; +} + +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; + +/** + * @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({ + ...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 = (key: K): WritableComputedRef => + 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; +} diff --git a/vue/toolkit/src/composables/browser/usePreferredColorScheme/index.test.ts b/vue/toolkit/src/composables/browser/usePreferredColorScheme/index.test.ts new file mode 100644 index 0000000..b9a1fa8 --- /dev/null +++ b/vue/toolkit/src/composables/browser/usePreferredColorScheme/index.test.ts @@ -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; + 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; + 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; + scope.run(() => { + scheme = usePreferredColorScheme(); + }); + await nextTick(); + expect(scheme!.value).toBe('no-preference'); + scope.stop(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/usePreferredColorScheme/index.ts b/vue/toolkit/src/composables/browser/usePreferredColorScheme/index.ts new file mode 100644 index 0000000..4d773c7 --- /dev/null +++ b/vue/toolkit/src/composables/browser/usePreferredColorScheme/index.ts @@ -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} `'dark'`, `'light'`, or `'no-preference'` + * + * @example + * const scheme = usePreferredColorScheme(); + * + * @since 0.0.15 + */ +export function usePreferredColorScheme( + options: ConfigurableWindow = {}, +): ComputedRef { + 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'; + }); +} diff --git a/vue/toolkit/src/composables/browser/usePreferredDark/index.test.ts b/vue/toolkit/src/composables/browser/usePreferredDark/index.test.ts new file mode 100644 index 0000000..e889184 --- /dev/null +++ b/vue/toolkit/src/composables/browser/usePreferredDark/index.test.ts @@ -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; + scope.run(() => { + isDark = usePreferredDark(); + }); + await nextTick(); + + expect(isDark!.value).toBeTruthy(); + scope.stop(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/usePreferredDark/index.ts b/vue/toolkit/src/composables/browser/usePreferredDark/index.ts new file mode 100644 index 0000000..85a9a19 --- /dev/null +++ b/vue/toolkit/src/composables/browser/usePreferredDark/index.ts @@ -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} Whether the user prefers a dark color scheme + * + * @example + * const isDark = usePreferredDark(); + * + * @since 0.0.15 + */ +export function usePreferredDark(options: ConfigurableWindow = {}): Ref { + return useMediaQuery('(prefers-color-scheme: dark)', options); +} diff --git a/vue/toolkit/src/composables/browser/useRafFn/index.test.ts b/vue/toolkit/src/composables/browser/useRafFn/index.test.ts index 5860cb5..803c5c7 100644 --- a/vue/toolkit/src/composables/browser/useRafFn/index.test.ts +++ b/vue/toolkit/src/composables/browser/useRafFn/index.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { defineComponent, effectScope, nextTick } from 'vue'; import { mount } from '@vue/test-utils'; import { useRafFn } from '.'; diff --git a/vue/toolkit/src/composables/browser/useResizeObserver/index.test.ts b/vue/toolkit/src/composables/browser/useResizeObserver/index.test.ts new file mode 100644 index 0000000..deb30d9 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useResizeObserver/index.test.ts @@ -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; disconnect: ReturnType }> = []; + +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(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(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(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; + 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; + 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(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useResizeObserver/index.ts b/vue/toolkit/src/composables/browser/useResizeObserver/index.ts new file mode 100644 index 0000000..21015e9 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useResizeObserver/index.ts @@ -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>; + + /** + * Whether the observer is currently active + */ + isActive: Readonly>; + + /** + * 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, + }; +} diff --git a/vue/toolkit/src/composables/browser/useScreenOrientation/index.test.ts b/vue/toolkit/src/composables/browser/useScreenOrientation/index.test.ts new file mode 100644 index 0000000..b301521 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useScreenOrientation/index.test.ts @@ -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; + unlock: ReturnType; + 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(); + + 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(); + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + scope.run(() => { + result = useScreenOrientation({ window }); + }); + await nextTick(); + + expect(result!.isSupported.value).toBeFalsy(); + await expect(result!.lockOrientation('portrait')).rejects.toThrow('Not supported'); + + scope.stop(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useScreenOrientation/index.ts b/vue/toolkit/src/composables/browser/useScreenOrientation/index.ts new file mode 100644 index 0000000..27c529b --- /dev/null +++ b/vue/toolkit/src/composables/browser/useScreenOrientation/index.ts @@ -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; + unlock: () => void; +} + +export interface UseScreenOrientationOptions extends ConfigurableWindow {} + +export interface UseScreenOrientationReturn { + /** + * Whether the Screen Orientation API is supported + */ + isSupported: ComputedRef; + /** + * Current screen orientation type, or `undefined` when unsupported + */ + orientation: ShallowRef; + /** + * Current screen orientation angle in degrees (defaults to `0`) + */ + angle: ShallowRef; + /** + * Lock the screen to the given orientation. Rejects when unsupported. + */ + lockOrientation: (type: OrientationLockType) => Promise; + /** + * 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(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 => { + 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, + }; +} diff --git a/vue/toolkit/src/composables/browser/useScroll/index.test.ts b/vue/toolkit/src/composables/browser/useScroll/index.test.ts new file mode 100644 index 0000000..c941124 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useScroll/index.test.ts @@ -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(fn: () => T): { result: T; scope: ReturnType } { + 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(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useScroll/index.ts b/vue/toolkit/src/composables/browser/useScroll/index.ts new file mode 100644 index 0000000..17df52e --- /dev/null +++ b/vue/toolkit/src/composables/browser/useScroll/index.ts @@ -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; + +export interface UseScrollEdgeState { + left: boolean; + right: boolean; + top: boolean; + bottom: boolean; +} + +export interface UseScrollReturn { + x: Ref; + y: Ref; + isScrolling: Ref; + 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({ left: true, right: false, top: true, bottom: false }); + const directions = reactive({ 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({ + get: () => internalX.value, + set: value => scrollTo('x', value), + }); + + const y = computed({ + 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, + '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, + }; +} diff --git a/vue/toolkit/src/composables/browser/useScrollLock/index.test.ts b/vue/toolkit/src/composables/browser/useScrollLock/index.test.ts new file mode 100644 index 0000000..abc5aaf --- /dev/null +++ b/vue/toolkit/src/composables/browser/useScrollLock/index.test.ts @@ -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; + 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; + 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; + 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; + 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; + 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; + 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; + 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(null); + const scope = effectScope(); + let isLocked: ReturnType; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useScrollLock/index.ts b/vue/toolkit/src/composables/browser/useScrollLock/index.ts new file mode 100644 index 0000000..a27ef09 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useScrollLock/index.ts @@ -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; + +/** + * 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(); + +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('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({ + get() { + return isLocked.value; + }, + set(value) { + if (value) + lock(); + else + unlock(); + }, + }); +} diff --git a/vue/toolkit/src/composables/browser/useShare/index.test.ts b/vue/toolkit/src/composables/browser/useShare/index.test.ts new file mode 100644 index 0000000..62f5b45 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useShare/index.test.ts @@ -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(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(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useShare/index.ts b/vue/toolkit/src/composables/browser/useShare/index.ts new file mode 100644 index 0000000..3e847d5 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useShare/index.ts @@ -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; + canShare?: (data?: UseShareOptions) => boolean; +} + +export interface UseShareReturn { + /** + * Whether the Web Share API is available + */ + isSupported: Readonly>; + + /** + * 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) => Promise; +} + +/** + * @name useShare + * @category Browser + * @description Reactive Web Share API wrapper to invoke the native share sheet. + * + * @param {MaybeRefOrGetter} [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 = {}, + 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 = {}): Promise => { + 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, + }; +} diff --git a/vue/toolkit/src/composables/browser/useSupported/index.test.ts b/vue/toolkit/src/composables/browser/useSupported/index.test.ts index 22ce0b7..817dda1 100644 --- a/vue/toolkit/src/composables/browser/useSupported/index.test.ts +++ b/vue/toolkit/src/composables/browser/useSupported/index.test.ts @@ -1,5 +1,5 @@ import { defineComponent } from 'vue'; -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { useSupported } from '.'; import { mount } from '@vue/test-utils'; diff --git a/vue/toolkit/src/composables/browser/useSupported/index.ts b/vue/toolkit/src/composables/browser/useSupported/index.ts index 413012e..b6a065a 100644 --- a/vue/toolkit/src/composables/browser/useSupported/index.ts +++ b/vue/toolkit/src/composables/browser/useSupported/index.ts @@ -21,9 +21,8 @@ export function useSupported(feature: () => unknown) { const isMounted = useMounted(); return computed(() => { - // add reactive dependency on isMounted - // eslint-disable-next-line no-unused-expressions - isMounted.value; + // Touch isMounted to register it as a reactive dependency + void isMounted.value; return Boolean(feature()); }); diff --git a/vue/toolkit/src/composables/browser/useSwipe/index.test.ts b/vue/toolkit/src/composables/browser/useSwipe/index.test.ts new file mode 100644 index 0000000..b21101b --- /dev/null +++ b/vue/toolkit/src/composables/browser/useSwipe/index.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it, vi } from 'vitest'; +import { effectScope, nextTick, ref } from 'vue'; +import { useSwipe } from '.'; + +function withScope(fn: () => T): { result: T; scope: ReturnType } { + const scope = effectScope(); + let result!: T; + scope.run(() => { + result = fn(); + }); + return { result, scope }; +} + +interface TouchPoint { + clientX: number; + clientY: number; +} + +// jsdom does not implement the Touch/TouchEvent constructors fully; build a +// minimal event carrying the `touches` list our composable reads. +function dispatchTouch(el: EventTarget, type: string, points: TouchPoint[]): TouchEvent { + const event = new Event(type, { bubbles: true, cancelable: true }) as unknown as TouchEvent; + Object.defineProperty(event, 'touches', { + value: points.map(p => ({ clientX: p.clientX, clientY: p.clientY })), + configurable: true, + }); + el.dispatchEvent(event as unknown as Event); + return event; +} + +function makeTarget(): HTMLElement { + return document.createElement('div'); +} + +describe(useSwipe, () => { + it('starts idle with zeroed coordinates and no direction', () => { + const el = makeTarget(); + const { result, scope } = withScope(() => useSwipe(ref(el))); + + expect(result.isSwiping.value).toBeFalsy(); + expect(result.direction.value).toBe('none'); + expect(result.coordsStart.x).toBe(0); + expect(result.coordsEnd.y).toBe(0); + expect(result.lengthX.value).toBe(0); + expect(result.lengthY.value).toBe(0); + scope.stop(); + }); + + it('records start coordinates and fires onSwipeStart on touchstart', () => { + const el = makeTarget(); + const onSwipeStart = vi.fn(); + const { result, scope } = withScope(() => useSwipe(ref(el), { onSwipeStart })); + + dispatchTouch(el, 'touchstart', [{ clientX: 100, clientY: 100 }]); + + expect(onSwipeStart).toHaveBeenCalledTimes(1); + expect(result.coordsStart.x).toBe(100); + expect(result.coordsStart.y).toBe(100); + expect(result.isSwiping.value).toBeFalsy(); + scope.stop(); + }); + + it('detects a left swipe once the threshold is exceeded', () => { + const el = makeTarget(); + const onSwipe = vi.fn(); + const onSwipeEnd = vi.fn(); + const { result, scope } = withScope(() => + useSwipe(ref(el), { threshold: 50, onSwipe, onSwipeEnd })); + + dispatchTouch(el, 'touchstart', [{ clientX: 200, clientY: 100 }]); + // Move left less than threshold: not swiping yet. + dispatchTouch(el, 'touchmove', [{ clientX: 180, clientY: 100 }]); + expect(result.isSwiping.value).toBeFalsy(); + expect(onSwipe).not.toHaveBeenCalled(); + + // Move past the threshold. + dispatchTouch(el, 'touchmove', [{ clientX: 100, clientY: 100 }]); + expect(result.isSwiping.value).toBeTruthy(); + expect(onSwipe).toHaveBeenCalled(); + expect(result.direction.value).toBe('left'); + expect(result.lengthX.value).toBe(-100); + expect(result.lengthY.value).toBe(0); + + dispatchTouch(el, 'touchend', [{ clientX: 100, clientY: 100 }]); + expect(onSwipeEnd).toHaveBeenCalledTimes(1); + expect(onSwipeEnd.mock.calls[0]![1]).toBe('left'); + expect(result.isSwiping.value).toBeFalsy(); + scope.stop(); + }); + + it('detects a right swipe', () => { + const el = makeTarget(); + const { result, scope } = withScope(() => useSwipe(ref(el))); + + dispatchTouch(el, 'touchstart', [{ clientX: 50, clientY: 100 }]); + dispatchTouch(el, 'touchmove', [{ clientX: 200, clientY: 100 }]); + + expect(result.direction.value).toBe('right'); + expect(result.lengthX.value).toBe(150); + scope.stop(); + }); + + it('detects up and down swipes on the dominant axis', () => { + const up = makeTarget(); + const { result: upResult, scope: upScope } = withScope(() => useSwipe(ref(up))); + dispatchTouch(up, 'touchstart', [{ clientX: 100, clientY: 200 }]); + dispatchTouch(up, 'touchmove', [{ clientX: 100, clientY: 100 }]); + expect(upResult.direction.value).toBe('up'); + expect(upResult.lengthY.value).toBe(-100); + upScope.stop(); + + const down = makeTarget(); + const { result: downResult, scope: downScope } = withScope(() => useSwipe(ref(down))); + dispatchTouch(down, 'touchstart', [{ clientX: 100, clientY: 50 }]); + dispatchTouch(down, 'touchmove', [{ clientX: 100, clientY: 200 }]); + expect(downResult.direction.value).toBe('down'); + expect(downResult.lengthY.value).toBe(150); + downScope.stop(); + }); + + it('ignores multi-touch gestures', () => { + const el = makeTarget(); + const onSwipeStart = vi.fn(); + const { result, scope } = withScope(() => useSwipe(ref(el), { onSwipeStart })); + + dispatchTouch(el, 'touchstart', [ + { clientX: 100, clientY: 100 }, + { clientX: 150, clientY: 150 }, + ]); + + expect(onSwipeStart).not.toHaveBeenCalled(); + expect(result.coordsStart.x).toBe(0); + scope.stop(); + }); + + it('does not call preventDefault when passive (default)', () => { + const el = makeTarget(); + const { scope } = withScope(() => useSwipe(ref(el))); + + dispatchTouch(el, 'touchstart', [{ clientX: 200, clientY: 100 }]); + const moveEvent = dispatchTouch(el, 'touchmove', [{ clientX: 100, clientY: 100 }]); + + expect(moveEvent.defaultPrevented).toBeFalsy(); + scope.stop(); + }); + + it('calls preventDefault on horizontal move when passive is false', () => { + const el = makeTarget(); + const { scope } = withScope(() => useSwipe(ref(el), { passive: false })); + + dispatchTouch(el, 'touchstart', [{ clientX: 200, clientY: 100 }]); + // Horizontal dominant move should preventDefault to block scrolling. + const moveEvent = dispatchTouch(el, 'touchmove', [{ clientX: 100, clientY: 100 }]); + expect(moveEvent.defaultPrevented).toBeTruthy(); + + // Vertical dominant move should NOT preventDefault. + dispatchTouch(el, 'touchstart', [{ clientX: 100, clientY: 200 }]); + const verticalMove = dispatchTouch(el, 'touchmove', [{ clientX: 100, clientY: 100 }]); + expect(verticalMove.defaultPrevented).toBeFalsy(); + scope.stop(); + }); + + it('respects a custom threshold', () => { + const el = makeTarget(); + const { result, scope } = withScope(() => useSwipe(ref(el), { threshold: 200 })); + + dispatchTouch(el, 'touchstart', [{ clientX: 0, clientY: 100 }]); + dispatchTouch(el, 'touchmove', [{ clientX: 150, clientY: 100 }]); + expect(result.isSwiping.value).toBeFalsy(); + expect(result.direction.value).toBe('none'); + + dispatchTouch(el, 'touchmove', [{ clientX: 250, clientY: 100 }]); + expect(result.isSwiping.value).toBeTruthy(); + expect(result.direction.value).toBe('right'); + scope.stop(); + }); + + it('stop() removes listeners so further touches are ignored', () => { + const el = makeTarget(); + const onSwipeStart = vi.fn(); + const { result, scope } = withScope(() => useSwipe(ref(el), { onSwipeStart })); + + result.stop(); + + dispatchTouch(el, 'touchstart', [{ clientX: 100, clientY: 100 }]); + expect(onSwipeStart).not.toHaveBeenCalled(); + expect(result.coordsStart.x).toBe(0); + scope.stop(); + }); + + it('re-binds listeners when the target ref changes', async () => { + const a = makeTarget(); + const b = makeTarget(); + const target = ref(a); + const onSwipeStart = vi.fn(); + const { scope } = withScope(() => useSwipe(target, { onSwipeStart })); + + dispatchTouch(a, 'touchstart', [{ clientX: 10, clientY: 10 }]); + expect(onSwipeStart).toHaveBeenCalledTimes(1); + + target.value = b; + await nextTick(); + + // Old target no longer listened to. + dispatchTouch(a, 'touchstart', [{ clientX: 10, clientY: 10 }]); + expect(onSwipeStart).toHaveBeenCalledTimes(1); + + // New target is. + dispatchTouch(b, 'touchstart', [{ clientX: 20, clientY: 20 }]); + expect(onSwipeStart).toHaveBeenCalledTimes(2); + scope.stop(); + }); + + it('does nothing and stays safe when window is undefined (SSR)', () => { + const el = makeTarget(); + const { result, scope } = withScope(() => + useSwipe(ref(el), { window: undefined })); + + expect(result.isSwiping.value).toBeFalsy(); + expect(result.direction.value).toBe('none'); + expect(() => result.stop()).not.toThrow(); + + // No listeners registered, so a touch is a no-op. + dispatchTouch(el, 'touchstart', [{ clientX: 100, clientY: 100 }]); + expect(result.coordsStart.x).toBe(0); + scope.stop(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useSwipe/index.ts b/vue/toolkit/src/composables/browser/useSwipe/index.ts new file mode 100644 index 0000000..0625129 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useSwipe/index.ts @@ -0,0 +1,221 @@ +import { computed, reactive, shallowReadonly, shallowRef } from 'vue'; +import type { ComputedRef, DeepReadonly, 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'; + +export type UseSwipeDirection = 'up' | 'down' | 'left' | 'right' | 'none'; + +export interface UseSwipePosition { + x: number; + y: number; +} + +export interface UseSwipeOptions extends ConfigurableWindow { + /** + * Register events as passive. + * + * When `false`, the move handler can call `preventDefault()` to block + * scrolling while swiping horizontally. + * + * @default true + */ + passive?: boolean; + + /** + * Minimum distance in pixels travelled before a swipe is registered. + * + * @default 50 + */ + threshold?: number; + + /** + * Callback fired on the initial touch, before the threshold is met. + */ + onSwipeStart?: (event: TouchEvent) => void; + + /** + * Callback fired on every move once the swipe is active. + */ + onSwipe?: (event: TouchEvent) => void; + + /** + * Callback fired when the touch ends, with the resolved direction. + */ + onSwipeEnd?: (event: TouchEvent, direction: UseSwipeDirection) => void; +} + +export interface UseSwipeReturn { + /** + * Whether a swipe is currently in progress (threshold exceeded). + */ + isSwiping: ShallowRef; + + /** + * The resolved swipe direction. + */ + direction: ComputedRef; + + /** + * Coordinates of the initial touch. + */ + coordsStart: DeepReadonly; + + /** + * Coordinates of the latest touch position. + */ + coordsEnd: DeepReadonly; + + /** + * Signed horizontal distance travelled (`coordsEnd.x - coordsStart.x`). + */ + lengthX: ComputedRef; + + /** + * Signed vertical distance travelled (`coordsEnd.y - coordsStart.y`). + */ + lengthY: ComputedRef; + + /** + * Tear down all listeners. + */ + stop: () => void; +} + +/** + * @name useSwipe + * @category Browser + * @description Detect swipe gestures via touch events on a target element. + * Tracks start/end coordinates, the active state and the resolved direction. + * + * @param {MaybeComputedElementRef} target - Element ref, getter, or instance to listen on + * @param {UseSwipeOptions} [options={}] - Threshold, lifecycle callbacks and passive flag + * @returns {UseSwipeReturn} Reactive swipe state and a `stop` teardown function + * + * @example + * const el = useTemplateRef('el'); + * const { isSwiping, direction, lengthX, lengthY } = useSwipe(el, { + * onSwipeEnd(e, dir) { console.log(dir); }, + * }); + * + * @since 0.0.15 + */ +export function useSwipe( + target: MaybeComputedElementRef, + options: UseSwipeOptions = {}, +): UseSwipeReturn { + const { + passive = true, + threshold = 50, + onSwipe, + onSwipeEnd, + onSwipeStart, + window = defaultWindow, + } = options; + + const coordsStart = reactive({ x: 0, y: 0 }); + const coordsEnd = reactive({ x: 0, y: 0 }); + + // diffX/diffY follow the start-minus-end convention used for direction math. + const diffX = computed(() => coordsStart.x - coordsEnd.x); + const diffY = computed(() => coordsStart.y - coordsEnd.y); + + // Public lengths report the signed travel from start to end. + const lengthX = computed(() => coordsEnd.x - coordsStart.x); + const lengthY = computed(() => coordsEnd.y - coordsStart.y); + + const { max, abs } = Math; + const isThresholdExceeded = computed(() => max(abs(diffX.value), abs(diffY.value)) >= threshold); + + const isSwiping = shallowRef(false); + + const direction = computed(() => { + if (!isThresholdExceeded.value) + return 'none'; + + if (abs(diffX.value) > abs(diffY.value)) + return diffX.value > 0 ? 'left' : 'right'; + + return diffY.value > 0 ? 'up' : 'down'; + }); + + // capture: !passive lets the move handler call preventDefault when blocking + // native scrolling during a horizontal swipe. + const listenerOptions = { passive, capture: !passive }; + + const updateCoordsStart = (x: number, y: number): void => { + coordsStart.x = x; + coordsStart.y = y; + }; + + const updateCoordsEnd = (x: number, y: number): void => { + coordsEnd.x = x; + coordsEnd.y = y; + }; + + const getTouchEventCoords = (event: TouchEvent): [number, number] => [ + event.touches[0]!.clientX, + event.touches[0]!.clientY, + ]; + + const onTouchStart = (event: TouchEvent): void => { + if (event.touches.length !== 1) + return; + + const [x, y] = getTouchEventCoords(event); + updateCoordsStart(x, y); + updateCoordsEnd(x, y); + onSwipeStart?.(event); + }; + + const onTouchMove = (event: TouchEvent): void => { + if (event.touches.length !== 1) + return; + + const [x, y] = getTouchEventCoords(event); + updateCoordsEnd(x, y); + + if (!listenerOptions.passive && abs(diffX.value) > abs(diffY.value)) + event.preventDefault(); + + if (!isSwiping.value && isThresholdExceeded.value) + isSwiping.value = true; + + if (isSwiping.value) + onSwipe?.(event); + }; + + const onTouchEnd = (event: TouchEvent): void => { + if (isSwiping.value) + onSwipeEnd?.(event, direction.value); + + isSwiping.value = false; + }; + + // Resolve the listen target lazily so listeners re-bind when the underlying + // element changes (template refs resolve after mount). + const listenTarget = (): EventTarget | null | undefined => + unrefElement(target) as EventTarget | null | undefined; + + const stops = window + ? [ + useEventListener(listenTarget, 'touchstart', onTouchStart as (e: Event) => void, listenerOptions), + useEventListener(listenTarget, 'touchmove', onTouchMove as (e: Event) => void, listenerOptions), + useEventListener(listenTarget, ['touchend', 'touchcancel'], onTouchEnd as (e: Event) => void, listenerOptions), + ] + : []; + + const stop = (): void => stops.forEach(s => s()); + + return { + isSwiping, + direction: shallowReadonly(direction) as ComputedRef, + coordsStart: shallowReadonly(coordsStart), + coordsEnd: shallowReadonly(coordsEnd), + lengthX, + lengthY, + stop, + }; +} diff --git a/vue/toolkit/src/composables/browser/useTabLeader/index.test.ts b/vue/toolkit/src/composables/browser/useTabLeader/index.test.ts index cf502a5..4541483 100644 --- a/vue/toolkit/src/composables/browser/useTabLeader/index.test.ts +++ b/vue/toolkit/src/composables/browser/useTabLeader/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { defineComponent, effectScope, nextTick } from 'vue'; import { mount } from '@vue/test-utils'; import { useTabLeader } from '.'; diff --git a/vue/toolkit/src/composables/browser/useTabLeader/index.ts b/vue/toolkit/src/composables/browser/useTabLeader/index.ts index 42f0e6e..5d094b5 100644 --- a/vue/toolkit/src/composables/browser/useTabLeader/index.ts +++ b/vue/toolkit/src/composables/browser/useTabLeader/index.ts @@ -1,5 +1,5 @@ -import { ref, readonly } from 'vue'; -import type { Ref, DeepReadonly, ComputedRef } from 'vue'; +import { readonly, ref } from 'vue'; +import type { ComputedRef, DeepReadonly, Ref } from 'vue'; import { useSupported } from '@/composables/browser/useSupported'; import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose'; diff --git a/vue/toolkit/src/composables/browser/useTextSelection/index.test.ts b/vue/toolkit/src/composables/browser/useTextSelection/index.test.ts new file mode 100644 index 0000000..5a7aa75 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useTextSelection/index.test.ts @@ -0,0 +1,252 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, nextTick } from 'vue'; +import { useTextSelection } from '.'; + +afterEach(() => { + vi.unstubAllGlobals(); + // Clear any selection so tests stay isolated. + globalThis.getSelection()?.removeAllRanges(); + document.body.innerHTML = ''; +}); + +/** + * jsdom never auto-fires `selectionchange`, so mutate the live selection and + * dispatch the event ourselves to mimic real browser behavior. + */ +function selectContents(node: Node): void { + const selection = globalThis.getSelection()!; + selection.removeAllRanges(); + const range = document.createRange(); + range.selectNodeContents(node); + selection.addRange(range); + document.dispatchEvent(new Event('selectionchange')); +} + +function clearSelection(): void { + globalThis.getSelection()?.removeAllRanges(); + document.dispatchEvent(new Event('selectionchange')); +} + +describe(useTextSelection, () => { + it('starts empty when nothing is selected', () => { + const scope = effectScope(); + let state: ReturnType; + scope.run(() => { + state = useTextSelection(); + }); + + expect(state!.text.value).toBe(''); + expect(state!.ranges.value).toEqual([]); + expect(state!.rects.value).toEqual([]); + scope.stop(); + }); + + it('tracks the selected text on selectionchange', async () => { + const div = document.createElement('div'); + div.textContent = 'Hello World'; + document.body.appendChild(div); + + const scope = effectScope(); + let state: ReturnType; + scope.run(() => { + state = useTextSelection(); + }); + + selectContents(div); + await nextTick(); + + expect(state!.text.value).toBe('Hello World'); + scope.stop(); + }); + + it('exposes the ranges that make up the selection', async () => { + const div = document.createElement('div'); + div.textContent = 'Ranges here'; + document.body.appendChild(div); + + const scope = effectScope(); + let state: ReturnType; + scope.run(() => { + state = useTextSelection(); + }); + + selectContents(div); + await nextTick(); + + expect(state!.ranges.value).toHaveLength(1); + expect(state!.ranges.value[0]).toBeInstanceOf(Range); + expect(state!.ranges.value[0]!.toString()).toBe('Ranges here'); + scope.stop(); + }); + + it('maps each range to its bounding rect', async () => { + const div = document.createElement('div'); + div.textContent = 'Rect me'; + document.body.appendChild(div); + + // jsdom Range lacks getBoundingClientRect; define it before spying. + const fakeRect = { x: 1, y: 2, width: 3, height: 4, top: 2, left: 1, right: 4, bottom: 6 } as DOMRect; + const original = (Range.prototype as { getBoundingClientRect?: () => DOMRect }).getBoundingClientRect; + (Range.prototype as { getBoundingClientRect: () => DOMRect }).getBoundingClientRect = () => fakeRect; + + const scope = effectScope(); + let state: ReturnType; + scope.run(() => { + state = useTextSelection(); + }); + + selectContents(div); + await nextTick(); + + expect(state!.rects.value).toEqual([fakeRect]); + + if (original) + (Range.prototype as { getBoundingClientRect: () => DOMRect }).getBoundingClientRect = original; + else + delete (Range.prototype as { getBoundingClientRect?: () => DOMRect }).getBoundingClientRect; + scope.stop(); + }); + + it('exposes the raw Selection object', async () => { + const div = document.createElement('div'); + div.textContent = 'Raw selection'; + document.body.appendChild(div); + + const scope = effectScope(); + let state: ReturnType; + scope.run(() => { + state = useTextSelection(); + }); + + selectContents(div); + await nextTick(); + + expect(state!.selection.value).not.toBeNull(); + expect(state!.selection.value!.toString()).toBe('Raw selection'); + scope.stop(); + }); + + it('resets when the selection is cleared', async () => { + const div = document.createElement('div'); + div.textContent = 'Will be cleared'; + document.body.appendChild(div); + + const scope = effectScope(); + let state: ReturnType; + scope.run(() => { + state = useTextSelection(); + }); + + selectContents(div); + await nextTick(); + expect(state!.text.value).toBe('Will be cleared'); + + clearSelection(); + await nextTick(); + + expect(state!.text.value).toBe(''); + expect(state!.ranges.value).toEqual([]); + expect(state!.rects.value).toEqual([]); + scope.stop(); + }); + + it('reacts to a new identity even when the browser mutates the same Selection in place', async () => { + const a = document.createElement('div'); + a.textContent = 'First'; + const b = document.createElement('div'); + b.textContent = 'Second'; + document.body.append(a, b); + + const scope = effectScope(); + let state: ReturnType; + scope.run(() => { + state = useTextSelection(); + }); + + selectContents(a); + await nextTick(); + expect(state!.text.value).toBe('First'); + + selectContents(b); + await nextTick(); + expect(state!.text.value).toBe('Second'); + scope.stop(); + }); + + it('cleans up the listener when the scope is disposed', () => { + const add = vi.fn(); + const remove = vi.fn(); + const windowStub = { getSelection: () => null } as unknown as Window; + const documentStub = { + addEventListener: add, + removeEventListener: remove, + } as unknown as Document; + + const scope = effectScope(); + scope.run(() => { + useTextSelection({ window: windowStub, document: documentStub }); + }); + + expect(add).toHaveBeenCalledWith('selectionchange', expect.any(Function), expect.anything()); + expect(remove).not.toHaveBeenCalled(); + + scope.stop(); + + expect(remove).toHaveBeenCalledWith('selectionchange', expect.any(Function), expect.anything()); + }); + + it('accepts a custom document/window via options', async () => { + let listener: ((event: Event) => void) | undefined; + const selectionStub = { + _text: '', + rangeCount: 0, + toString() { return this._text; }, + getRangeAt: vi.fn(), + }; + const windowStub = { + getSelection: () => selectionStub as unknown as Selection, + } as unknown as Window; + const documentStub = { + addEventListener: (_type: string, cb: (event: Event) => void) => { listener = cb; }, + removeEventListener: vi.fn(), + } as unknown as Document; + + const scope = effectScope(); + let state: ReturnType; + scope.run(() => { + state = useTextSelection({ window: windowStub, document: documentStub }); + }); + + expect(state!.text.value).toBe(''); + + selectionStub._text = 'Custom doc text'; + listener?.(new Event('selectionchange')); + await nextTick(); + + expect(state!.text.value).toBe('Custom doc text'); + scope.stop(); + }); + + it('returns empty state and registers no listener when selection is unsupported', () => { + // Emulate an SSR / unsupported environment: getSelection yields null and the + // document never emits selectionchange. + const add = vi.fn(); + const windowStub = { getSelection: () => null } as unknown as Window; + const documentStub = { + addEventListener: add, + removeEventListener: vi.fn(), + } as unknown as Document; + + const scope = effectScope(); + let state: ReturnType; + scope.run(() => { + state = useTextSelection({ window: windowStub, document: documentStub }); + }); + + expect(state!.selection.value).toBeNull(); + expect(state!.text.value).toBe(''); + expect(state!.ranges.value).toEqual([]); + expect(state!.rects.value).toEqual([]); + scope.stop(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useTextSelection/index.ts b/vue/toolkit/src/composables/browser/useTextSelection/index.ts new file mode 100644 index 0000000..ff97f47 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useTextSelection/index.ts @@ -0,0 +1,81 @@ +import type { ComputedRef, ShallowRef } from 'vue'; +import { computed, shallowRef } from 'vue'; +import type { ConfigurableDocument, ConfigurableWindow } from '@/types'; +import { defaultWindow } from '@/types'; +import { useEventListener } from '@/composables/browser/useEventListener'; + +export interface UseTextSelectionOptions extends ConfigurableWindow, ConfigurableDocument {} + +export interface UseTextSelectionReturn { + /** + * The selected text, equivalent to `Selection.toString()`. + */ + text: ComputedRef; + /** + * Bounding rectangles for every range in the current selection. + */ + rects: ComputedRef; + /** + * The `Range` objects that make up the current selection. + */ + ranges: ComputedRef; + /** + * The raw `Selection` object, or `null` when nothing is selected / unsupported. + */ + selection: ShallowRef; +} + +function getRangesFromSelection(selection: Selection): Range[] { + const rangeCount = selection.rangeCount ?? 0; + const ranges: Range[] = []; + + for (let i = 0; i < rangeCount; i++) + ranges.push(selection.getRangeAt(i)); + + return ranges; +} + +/** + * @name useTextSelection + * @category Browser + * @description Reactively track the user's text selection via `Window.getSelection`. + * + * @param {UseTextSelectionOptions} [options={}] Options (custom `window`, `document`) + * @returns {UseTextSelectionReturn} Reactive `text`, `rects`, `ranges`, and the raw `selection` + * + * @example + * const { text, rects, ranges, selection } = useTextSelection(); + * watch(text, (value) => console.log('selected:', value)); + * + * @since 0.0.15 + */ +export function useTextSelection( + options: UseTextSelectionOptions = {}, +): UseTextSelectionReturn { + const { window = defaultWindow } = options; + const document = options.document ?? window?.document; + + const selection = shallowRef(window?.getSelection() ?? null); + const text = computed(() => selection.value?.toString() ?? ''); + const ranges = computed(() => (selection.value ? getRangesFromSelection(selection.value) : [])); + const rects = computed(() => ranges.value.map(range => range.getBoundingClientRect())); + + const onSelectionChange = (): void => { + // Reassign through `null` so the shallowRef sees a new identity even when the + // browser mutates and reuses the same live `Selection` object in place. + selection.value = null; + + if (window) + selection.value = window.getSelection(); + }; + + if (document) + useEventListener(document, 'selectionchange', onSelectionChange, { passive: true }); + + return { + text, + rects, + ranges, + selection, + }; +} diff --git a/vue/toolkit/src/composables/browser/useTitle/index.test.ts b/vue/toolkit/src/composables/browser/useTitle/index.test.ts new file mode 100644 index 0000000..606cd3c --- /dev/null +++ b/vue/toolkit/src/composables/browser/useTitle/index.test.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { effectScope, nextTick, ref } from 'vue'; +import { useTitle } from '.'; + +describe(useTitle, () => { + beforeEach(() => { + document.title = 'initial'; + }); + + it('reads the current document title', () => { + const scope = effectScope(); + let title: ReturnType; + scope.run(() => { + title = useTitle(); + }); + expect(title!.value).toBe('initial'); + scope.stop(); + }); + + it('writes the document title when the ref changes', async () => { + const scope = effectScope(); + let title: ReturnType; + scope.run(() => { + title = useTitle(); + }); + + title!.value = 'updated'; + await nextTick(); + expect(document.title).toBe('updated'); + scope.stop(); + }); + + it('applies a title template', () => { + const scope = effectScope(); + scope.run(() => useTitle('Page', { titleTemplate: '%s | App' })); + expect(document.title).toBe('Page | App'); + scope.stop(); + }); + + it('replaces every %s occurrence in the template', () => { + const scope = effectScope(); + scope.run(() => useTitle('X', { titleTemplate: '%s - %s' })); + expect(document.title).toBe('X - X'); + scope.stop(); + }); + + it('supports a function title template', () => { + const scope = effectScope(); + scope.run(() => useTitle('Home', { titleTemplate: t => `[${t}]` })); + expect(document.title).toBe('[Home]'); + scope.stop(); + }); + + it('reacts to a writable ref source', async () => { + const scope = effectScope(); + const source = ref('one'); + scope.run(() => useTitle(source)); + await nextTick(); + expect(document.title).toBe('one'); + scope.stop(); + }); + + it('returns a read-only ref when a getter source is passed and tracks it', async () => { + const scope = effectScope(); + const count = ref(0); + let title: ReturnType; + scope.run(() => { + title = useTitle(() => `Count ${count.value}`); + }); + + expect(document.title).toBe('Count 0'); + expect(title!.value).toBe('Count 0'); + + count.value = 5; + await nextTick(); + expect(document.title).toBe('Count 5'); + expect(title!.value).toBe('Count 5'); + scope.stop(); + }); + + it('restores the original title on scope dispose', async () => { + const scope = effectScope(); + scope.run(() => useTitle('Temp', { restoreOnUnmount: original => original })); + await nextTick(); + expect(document.title).toBe('Temp'); + + scope.stop(); + await nextTick(); + expect(document.title).toBe('initial'); + }); + + it('keeps the last title when restoreOnUnmount is omitted', async () => { + const scope = effectScope(); + scope.run(() => useTitle('Sticky')); + await nextTick(); + expect(document.title).toBe('Sticky'); + + scope.stop(); + await nextTick(); + expect(document.title).toBe('Sticky'); + }); + + it('does not restore when restoreOnUnmount returns null', async () => { + const scope = effectScope(); + scope.run(() => useTitle('Kept', { restoreOnUnmount: () => null })); + await nextTick(); + expect(document.title).toBe('Kept'); + + scope.stop(); + await nextTick(); + expect(document.title).toBe('Kept'); + }); + + it('syncs external title changes back to the ref when observing', async () => { + const scope = effectScope(); + let title: ReturnType; + scope.run(() => { + title = useTitle(null, { observe: true }); + }); + await nextTick(); + + const titleEl = document.head.querySelector('title'); + expect(titleEl).not.toBeNull(); + + document.title = 'external'; + // Wait for the MutationObserver callback to flush + await new Promise(resolve => setTimeout(resolve, 0)); + expect(title!.value).toBe('external'); + scope.stop(); + }); + + it('is a no-op without a document (SSR-safe)', () => { + const scope = effectScope(); + let title: ReturnType; + scope.run(() => { + title = useTitle('SSR', { document: undefined }); + }); + expect(title!.value).toBe('SSR'); + scope.stop(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useTitle/index.ts b/vue/toolkit/src/composables/browser/useTitle/index.ts new file mode 100644 index 0000000..62ee37b --- /dev/null +++ b/vue/toolkit/src/composables/browser/useTitle/index.ts @@ -0,0 +1,145 @@ +import { computed, ref, toValue, watch } from 'vue'; +import type { ComputedRef, MaybeRef, MaybeRefOrGetter, Ref } from 'vue'; +import { isFunction, isString } from '@robonen/stdlib'; +import { defaultDocument } from '@/types'; +import type { ConfigurableDocument } from '@/types'; +import { useMutationObserver } from '@/composables/browser/useMutationObserver'; +import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose'; + +export interface UseTitleOptionsBase extends ConfigurableDocument { + /** + * Observe the `` element for external changes and sync them back to the ref. + * Ignored when `titleTemplate` is provided, to avoid a write/observe feedback loop. + * + * @default false + */ + observe?: boolean; + + /** + * Template used to format the title. Every `%s` is replaced with the value. + * + * @default '%s' + */ + titleTemplate?: MaybeRef<string> | ((title: string) => string); + + /** + * Restore the original document title when the active scope is disposed. + * Pass a function to compute the title to restore, or `false` to keep the + * last value in place. + * + * @default false + */ + restoreOnUnmount?: false | ((originalTitle: string, currentTitle: string) => string | null | undefined); +} + +export type UseTitleOptions = UseTitleOptionsBase; + +export type UseTitleReturn = Ref<string | null | undefined> | ComputedRef<string | null | undefined>; + +/** + * @name useTitle + * @category Browser + * @description Reactive `document.title`. Pass a getter to derive the title from + * other reactive state (returns a read-only ref), or a plain value/ref for two-way binding. + * + * @param {MaybeRefOrGetter<string | null | undefined>} [newTitle] Initial title (getter source returns a read-only ref) + * @param {UseTitleOptions} [options={}] Options + * @returns {UseTitleReturn} A ref bound to the document title (read-only when a getter source is passed) + * + * @example + * const title = useTitle(); + * title.value = 'New title'; + * + * @example + * useTitle('Dashboard', { titleTemplate: '%s | My App' }); + * + * @example + * // Derive from reactive state (read-only result) + * useTitle(() => `Inbox (${count.value})`); + * + * @example + * // Restore the previous title when the component unmounts + * useTitle('Checkout', { restoreOnUnmount: (original) => original }); + * + * @since 0.0.15 + */ +export function useTitle( + newTitle: () => string | null | undefined, + options?: UseTitleOptions, +): ComputedRef<string | null | undefined>; +export function useTitle( + newTitle?: MaybeRef<string | null | undefined>, + options?: UseTitleOptions, +): Ref<string | null | undefined>; +export function useTitle( + newTitle: MaybeRefOrGetter<string | null | undefined> = null, + options: UseTitleOptions = {}, +): UseTitleReturn { + const { + document = defaultDocument, + observe = false, + titleTemplate = '%s', + restoreOnUnmount = false, + } = options; + + const originalTitle = document?.title ?? ''; + const hasTemplate = 'titleTemplate' in options; + + const isReadonly = isFunction(newTitle); + + const title = ref<string | null | undefined>(toValue(newTitle) ?? document?.title ?? null); + + const format = (value: string): string => { + if (!hasTemplate) + return value; + + return isFunction(titleTemplate) + ? titleTemplate(value) + : toValue(titleTemplate).split('%s').join(value); + }; + + watch( + title, + (value, oldValue) => { + if (value !== oldValue && document) + document.title = format(isString(value) ? value : ''); + }, + { immediate: true }, + ); + + // Keep a read-only ref in sync when the getter source changes + if (isReadonly) { + watch( + () => toValue(newTitle), + (value) => { + title.value = value; + }, + ); + } + + // Observing only makes sense without a template, otherwise the formatted + // write would feed back through the observer. + if (observe && !hasTemplate && document && !isReadonly) { + useMutationObserver( + document.head?.querySelector('title'), + () => { + if (document && document.title !== title.value) + title.value = document.title; + }, + { childList: true }, + ); + } + + if (restoreOnUnmount) { + tryOnScopeDispose(() => { + const restored = restoreOnUnmount(originalTitle, isString(title.value) ? title.value : ''); + if (restored !== null && restored !== undefined && document) + document.title = restored; + }); + } + + if (isReadonly) + return computed(() => title.value); + + return title; +} diff --git a/vue/toolkit/src/composables/browser/useVibrate/index.test.ts b/vue/toolkit/src/composables/browser/useVibrate/index.test.ts new file mode 100644 index 0000000..e3d53f4 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useVibrate/index.test.ts @@ -0,0 +1,198 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, nextTick, ref } from 'vue'; +import { useVibrate } from '.'; + +function stubNavigator() { + const vibrate = vi.fn(() => true); + const navigator = { vibrate } as unknown as Navigator; + return { navigator, vibrate }; +} + +describe(useVibrate, () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('reports support based on the provided navigator', () => { + const { navigator } = stubNavigator(); + const scope = effectScope(); + let result: ReturnType<typeof useVibrate>; + scope.run(() => { + result = useVibrate({ navigator }); + }); + + expect(result!.isSupported.value).toBeTruthy(); + scope.stop(); + }); + + it('reports unsupported when navigator lacks vibrate', () => { + const navigator = {} as Navigator; + const scope = effectScope(); + let result: ReturnType<typeof useVibrate>; + scope.run(() => { + result = useVibrate({ navigator }); + }); + + expect(result!.isSupported.value).toBeFalsy(); + scope.stop(); + }); + + it('reports unsupported when navigator is undefined (SSR path)', () => { + const scope = effectScope(); + let result: ReturnType<typeof useVibrate>; + scope.run(() => { + result = useVibrate({ navigator: undefined }); + }); + + expect(result!.isSupported.value).toBeFalsy(); + // vibrate/stop must not throw when unsupported + expect(() => result!.vibrate()).not.toThrow(); + expect(() => result!.stop()).not.toThrow(); + scope.stop(); + }); + + it('vibrates with the configured pattern by default', () => { + const { navigator, vibrate } = stubNavigator(); + const scope = effectScope(); + let result: ReturnType<typeof useVibrate>; + scope.run(() => { + result = useVibrate({ pattern: [200, 100, 200], navigator }); + }); + + result!.vibrate(); + expect(vibrate).toHaveBeenCalledWith([200, 100, 200]); + scope.stop(); + }); + + it('vibrates with a one-off pattern argument', () => { + const { navigator, vibrate } = stubNavigator(); + const scope = effectScope(); + let result: ReturnType<typeof useVibrate>; + scope.run(() => { + result = useVibrate({ pattern: [200], navigator }); + }); + + result!.vibrate(500); + expect(vibrate).toHaveBeenCalledWith(500); + scope.stop(); + }); + + it('exposes a reactive pattern that controls default vibration', () => { + const { navigator, vibrate } = stubNavigator(); + const scope = effectScope(); + let result: ReturnType<typeof useVibrate>; + scope.run(() => { + result = useVibrate({ pattern: [100], navigator }); + }); + + result!.pattern.value = [300, 50, 300]; + result!.vibrate(); + expect(vibrate).toHaveBeenCalledWith([300, 50, 300]); + scope.stop(); + }); + + it('accepts a ref pattern', () => { + const { navigator, vibrate } = stubNavigator(); + const pattern = ref<number[]>([10]); + const scope = effectScope(); + let result: ReturnType<typeof useVibrate>; + scope.run(() => { + result = useVibrate({ pattern, navigator }); + }); + + pattern.value = [20, 30]; + result!.vibrate(); + expect(vibrate).toHaveBeenCalledWith([20, 30]); + scope.stop(); + }); + + it('stop() cancels any ongoing vibration', () => { + const { navigator, vibrate } = stubNavigator(); + const scope = effectScope(); + let result: ReturnType<typeof useVibrate>; + scope.run(() => { + result = useVibrate({ pattern: [200], navigator }); + }); + + result!.stop(); + expect(vibrate).toHaveBeenCalledWith(0); + scope.stop(); + }); + + it('does not expose interval controls when interval is 0', () => { + const { navigator } = stubNavigator(); + const scope = effectScope(); + let result: ReturnType<typeof useVibrate>; + scope.run(() => { + result = useVibrate({ navigator }); + }); + + expect(result!.intervalControls).toBeUndefined(); + scope.stop(); + }); + + it('exposes interval controls when interval > 0 and loops the pattern', () => { + vi.useFakeTimers(); + const { navigator, vibrate } = stubNavigator(); + const scope = effectScope(); + let result: ReturnType<typeof useVibrate>; + scope.run(() => { + result = useVibrate({ pattern: [100], interval: 1000, navigator }); + }); + + expect(result!.intervalControls).toBeDefined(); + expect(result!.intervalControls!.isActive.value).toBeFalsy(); + + result!.intervalControls!.resume(); + expect(result!.intervalControls!.isActive.value).toBeTruthy(); + + vi.advanceTimersByTime(2500); + expect(vibrate).toHaveBeenCalledTimes(2); + expect(vibrate).toHaveBeenCalledWith([100]); + + scope.stop(); + }); + + it('stop() pauses the interval loop', () => { + vi.useFakeTimers(); + const { navigator, vibrate } = stubNavigator(); + const scope = effectScope(); + let result: ReturnType<typeof useVibrate>; + scope.run(() => { + result = useVibrate({ pattern: [100], interval: 1000, navigator }); + }); + + result!.intervalControls!.resume(); + vi.advanceTimersByTime(1000); + expect(vibrate).toHaveBeenCalledTimes(1); + + result!.stop(); + expect(result!.intervalControls!.isActive.value).toBeFalsy(); + + // vibrate(0) from stop plus the single loop tick + vibrate.mockClear(); + vi.advanceTimersByTime(3000); + expect(vibrate).not.toHaveBeenCalled(); + + scope.stop(); + }); + + it('stops the interval loop when the scope is disposed', async () => { + vi.useFakeTimers(); + const { navigator, vibrate } = stubNavigator(); + const scope = effectScope(); + let result: ReturnType<typeof useVibrate>; + scope.run(() => { + result = useVibrate({ pattern: [100], interval: 1000, navigator }); + }); + + result!.intervalControls!.resume(); + scope.stop(); + await nextTick(); + + vibrate.mockClear(); + vi.advanceTimersByTime(3000); + expect(vibrate).not.toHaveBeenCalled(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useVibrate/index.ts b/vue/toolkit/src/composables/browser/useVibrate/index.ts new file mode 100644 index 0000000..2d49651 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useVibrate/index.ts @@ -0,0 +1,115 @@ +import { toRef } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue'; +import type { Arrayable } from '@robonen/stdlib'; +import type { ConfigurableNavigator } from '@/types'; +import { defaultNavigator } from '@/types'; +import { useSupported } from '@/composables/browser/useSupported'; +import { useIntervalFn } from '@/composables/browser/useIntervalFn'; +import type { UseIntervalFnReturn } from '@/composables/browser/useIntervalFn'; + +export interface UseVibrateOptions extends ConfigurableNavigator { + /** + * The pattern of vibrations and pauses (in milliseconds). + * + * A single number vibrates for that many milliseconds; an array alternates + * between vibration and pause durations. + * + * @default [] + */ + pattern?: MaybeRefOrGetter<Arrayable<number>>; + + /** + * Interval (in milliseconds) to automatically run the vibration pattern on a loop. + * + * When greater than `0`, an `intervalControls` object is returned so the loop can + * be paused/resumed. The loop does not start automatically; call `vibrate()` once + * or use `intervalControls.resume()`. + * + * @default 0 + */ + interval?: number; +} + +export interface UseVibrateReturn { + /** + * Whether the Vibration API is supported in the current environment. + */ + isSupported: ComputedRef<boolean>; + + /** + * The reactive vibration pattern. Mutating it changes what `vibrate()` plays by default. + */ + pattern: Ref<Arrayable<number>>; + + /** + * Pause/resume controls for the interval loop. Only present when `interval > 0`. + */ + intervalControls?: UseIntervalFnReturn; + + /** + * Trigger a vibration. Falls back to the configured `pattern` when called without arguments. + * + * @param pattern - Optional one-off pattern to play instead of the configured one + */ + vibrate: (pattern?: Arrayable<number>) => void; + + /** + * Stop any ongoing vibration and pause the interval loop (if any). + */ + stop: () => void; +} + +/** + * @name useVibrate + * @category Browser + * @description Reactive wrapper around the `navigator.vibrate` Vibration API. + * + * @param {UseVibrateOptions} [options] Configuration options + * @returns {UseVibrateReturn} Support flag, reactive pattern, vibrate/stop actions, and optional interval controls + * + * @example + * const { vibrate, stop, isSupported } = useVibrate({ pattern: [200, 100, 200] }); + * vibrate(); + * + * @example + * // Loop a pattern on an interval + * const { vibrate, stop, intervalControls } = useVibrate({ pattern: [300, 100], interval: 2000 }); + * intervalControls?.resume(); + * + * @since 0.0.15 + */ +export function useVibrate(options: UseVibrateOptions = {}): UseVibrateReturn { + const { + pattern = [], + interval = 0, + navigator = defaultNavigator, + } = options; + + const isSupported = useSupported(() => typeof navigator !== 'undefined' && !!navigator && 'vibrate' in navigator); + + const patternRef = toRef(pattern); + + function vibrate(pattern: Arrayable<number> = patternRef.value): void { + if (isSupported.value) + navigator!.vibrate(pattern); + } + + const intervalControls: UseIntervalFnReturn | undefined = interval > 0 + ? useIntervalFn(vibrate, interval, { immediate: false }) + : undefined; + + function stop(): void { + if (isSupported.value) + navigator!.vibrate(0); + + intervalControls?.pause(); + } + + return { + isSupported, + pattern: patternRef, + intervalControls, + vibrate, + stop, + }; +} diff --git a/vue/toolkit/src/composables/browser/useWindowFocus/index.test.ts b/vue/toolkit/src/composables/browser/useWindowFocus/index.test.ts new file mode 100644 index 0000000..3a4af5c --- /dev/null +++ b/vue/toolkit/src/composables/browser/useWindowFocus/index.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest'; +import { effectScope, isReadonly } from 'vue'; +import { useWindowFocus } from '.'; + +interface FakeWindow { + document: { hasFocus: () => boolean }; + addEventListener: Window['addEventListener']; + removeEventListener: Window['removeEventListener']; + dispatchEvent: Window['dispatchEvent']; +} + +// Build a minimal window-like object whose event plumbing is driven by a real +// EventTarget, while `document.hasFocus()` is controllable for initial state. +function createFakeWindow(initialFocus: boolean): FakeWindow { + const target = new EventTarget(); + + return { + document: { hasFocus: () => initialFocus }, + addEventListener: target.addEventListener.bind(target) as Window['addEventListener'], + removeEventListener: target.removeEventListener.bind(target) as Window['removeEventListener'], + dispatchEvent: target.dispatchEvent.bind(target) as Window['dispatchEvent'], + }; +} + +describe(useWindowFocus, () => { + it('initialises from document.hasFocus() (focused)', () => { + const fakeWindow = createFakeWindow(true); + + const scope = effectScope(); + let focused: ReturnType<typeof useWindowFocus>; + scope.run(() => { + focused = useWindowFocus({ window: fakeWindow as unknown as Window }); + }); + + expect(focused!.value).toBeTruthy(); + + scope.stop(); + }); + + it('initialises from document.hasFocus() (blurred)', () => { + const fakeWindow = createFakeWindow(false); + + const scope = effectScope(); + let focused: ReturnType<typeof useWindowFocus>; + scope.run(() => { + focused = useWindowFocus({ window: fakeWindow as unknown as Window }); + }); + + expect(focused!.value).toBeFalsy(); + + scope.stop(); + }); + + it('becomes false on blur', () => { + const fakeWindow = createFakeWindow(true); + + const scope = effectScope(); + let focused: ReturnType<typeof useWindowFocus>; + scope.run(() => { + focused = useWindowFocus({ window: fakeWindow as unknown as Window }); + }); + + expect(focused!.value).toBeTruthy(); + + fakeWindow.dispatchEvent(new Event('blur')); + expect(focused!.value).toBeFalsy(); + + scope.stop(); + }); + + it('becomes true on focus', () => { + const fakeWindow = createFakeWindow(false); + + const scope = effectScope(); + let focused: ReturnType<typeof useWindowFocus>; + scope.run(() => { + focused = useWindowFocus({ window: fakeWindow as unknown as Window }); + }); + + expect(focused!.value).toBeFalsy(); + + fakeWindow.dispatchEvent(new Event('focus')); + expect(focused!.value).toBeTruthy(); + + scope.stop(); + }); + + it('tracks repeated focus/blur transitions', () => { + const fakeWindow = createFakeWindow(true); + + const scope = effectScope(); + let focused: ReturnType<typeof useWindowFocus>; + scope.run(() => { + focused = useWindowFocus({ window: fakeWindow as unknown as Window }); + }); + + fakeWindow.dispatchEvent(new Event('blur')); + expect(focused!.value).toBeFalsy(); + + fakeWindow.dispatchEvent(new Event('focus')); + expect(focused!.value).toBeTruthy(); + + fakeWindow.dispatchEvent(new Event('blur')); + expect(focused!.value).toBeFalsy(); + + scope.stop(); + }); + + it('removes listeners when the scope is disposed', () => { + const fakeWindow = createFakeWindow(true); + + const scope = effectScope(); + let focused: ReturnType<typeof useWindowFocus>; + scope.run(() => { + focused = useWindowFocus({ window: fakeWindow as unknown as Window }); + }); + + scope.stop(); + + // after disposal, events must no longer mutate the ref + fakeWindow.dispatchEvent(new Event('blur')); + expect(focused!.value).toBeTruthy(); + }); + + it('returns a writable shallow ref (not readonly)', () => { + const fakeWindow = createFakeWindow(true); + + const scope = effectScope(); + let focused: ReturnType<typeof useWindowFocus>; + scope.run(() => { + focused = useWindowFocus({ window: fakeWindow as unknown as Window }); + }); + + expect(isReadonly(focused!)).toBeFalsy(); + + scope.stop(); + }); + + it('returns false and does not throw when window is unavailable (SSR)', () => { + const scope = effectScope(); + let focused: ReturnType<typeof useWindowFocus>; + scope.run(() => { + focused = useWindowFocus({ window: undefined }); + }); + + expect(focused!.value).toBeFalsy(); + + scope.stop(); + }); + + it('uses the real jsdom window by default', () => { + const scope = effectScope(); + let focused: ReturnType<typeof useWindowFocus>; + scope.run(() => { + focused = useWindowFocus(); + }); + + // initial value mirrors document.hasFocus() + expect(focused!.value).toBe(document.hasFocus()); + + globalThis.dispatchEvent(new Event('blur')); + expect(focused!.value).toBeFalsy(); + + globalThis.dispatchEvent(new Event('focus')); + expect(focused!.value).toBeTruthy(); + + scope.stop(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useWindowFocus/index.ts b/vue/toolkit/src/composables/browser/useWindowFocus/index.ts new file mode 100644 index 0000000..a2d8944 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useWindowFocus/index.ts @@ -0,0 +1,43 @@ +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 UseWindowFocusOptions extends ConfigurableWindow {} + +export type UseWindowFocusReturn = ShallowRef<boolean>; + +/** + * @name useWindowFocus + * @category Browser + * @description Reactively track whether the window is focused via `focus`/`blur` events. + * + * @param {UseWindowFocusOptions} [options={}] Options + * @returns {UseWindowFocusReturn} A shallow ref that is `true` while the window has focus + * + * @example + * const focused = useWindowFocus(); + * + * @since 0.0.15 + */ +export function useWindowFocus(options: UseWindowFocusOptions = {}): UseWindowFocusReturn { + const { window = defaultWindow } = options; + + if (!window) + return shallowRef(false); + + const focused = shallowRef(window.document.hasFocus()); + + const listenerOptions = { passive: true } as const; + + useEventListener(window, 'blur', () => { + focused.value = false; + }, listenerOptions); + + useEventListener(window, 'focus', () => { + focused.value = true; + }, listenerOptions); + + return focused; +} diff --git a/vue/toolkit/src/composables/browser/useWindowScroll/index.test.ts b/vue/toolkit/src/composables/browser/useWindowScroll/index.test.ts new file mode 100644 index 0000000..c0e5b66 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useWindowScroll/index.test.ts @@ -0,0 +1,244 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, nextTick } from 'vue'; +import { useWindowScroll } from '.'; + +function setScroll(x: number, y: number): void { + (globalThis as any).scrollX = x; + (globalThis as any).scrollY = y; +} + +describe(useWindowScroll, () => { + beforeEach(() => { + Object.defineProperty(globalThis, 'scrollX', { value: 0, configurable: true, writable: true }); + Object.defineProperty(globalThis, 'scrollY', { value: 0, configurable: true, writable: true }); + }); + afterEach(() => vi.unstubAllGlobals()); + + it('reads the initial scroll position', () => { + setScroll(15, 25); + const scope = effectScope(); + let result: ReturnType<typeof useWindowScroll>; + scope.run(() => { + result = useWindowScroll(); + }); + + expect(result!.x.value).toBe(15); + expect(result!.y.value).toBe(25); + scope.stop(); + }); + + it('updates on scroll', async () => { + const scope = effectScope(); + let result: ReturnType<typeof useWindowScroll>; + scope.run(() => { + result = useWindowScroll(); + }); + + setScroll(30, 60); + globalThis.dispatchEvent(new Event('scroll')); + await nextTick(); + + expect(result!.x.value).toBe(30); + expect(result!.y.value).toBe(60); + scope.stop(); + }); + + it('scrolls the window when writing to x/y', () => { + const scrollTo = vi.fn(); + vi.stubGlobal('scrollTo', scrollTo); + + const scope = effectScope(); + let result: ReturnType<typeof useWindowScroll>; + scope.run(() => { + result = useWindowScroll(); + }); + + result!.x.value = 100; + expect(scrollTo).toHaveBeenCalledWith(expect.objectContaining({ left: 100 })); + result!.y.value = 200; + expect(scrollTo).toHaveBeenCalledWith(expect.objectContaining({ top: 200 })); + scope.stop(); + }); + + it('passes the configured behavior when writing to x/y', () => { + const scrollTo = vi.fn(); + vi.stubGlobal('scrollTo', scrollTo); + + const scope = effectScope(); + let result: ReturnType<typeof useWindowScroll>; + scope.run(() => { + result = useWindowScroll({ behavior: 'smooth' }); + }); + + result!.x.value = 50; + expect(scrollTo).toHaveBeenCalledWith(expect.objectContaining({ left: 50, behavior: 'smooth' })); + scope.stop(); + }); + + it('exposes isScrolling that toggles on scroll and resets after idle', async () => { + vi.useFakeTimers(); + const scope = effectScope(); + let result: ReturnType<typeof useWindowScroll>; + scope.run(() => { + result = useWindowScroll({ idle: 50 }); + }); + + expect(result!.isScrolling.value).toBeFalsy(); + + setScroll(10, 10); + globalThis.dispatchEvent(new Event('scroll')); + expect(result!.isScrolling.value).toBeTruthy(); + + vi.advanceTimersByTime(60); + await nextTick(); + expect(result!.isScrolling.value).toBeFalsy(); + + scope.stop(); + vi.useRealTimers(); + }); + + it('calls onScroll and onStop callbacks', async () => { + vi.useFakeTimers(); + const onScroll = vi.fn(); + const onStop = vi.fn(); + const scope = effectScope(); + scope.run(() => { + useWindowScroll({ idle: 50, onScroll, onStop }); + }); + + setScroll(5, 5); + globalThis.dispatchEvent(new Event('scroll')); + expect(onScroll).toHaveBeenCalledTimes(1); + expect(onStop).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(60); + await nextTick(); + expect(onStop).toHaveBeenCalledTimes(1); + + scope.stop(); + vi.useRealTimers(); + }); + + it('tracks scroll directions', () => { + const scope = effectScope(); + let result: ReturnType<typeof useWindowScroll>; + scope.run(() => { + result = useWindowScroll(); + }); + + setScroll(40, 80); + globalThis.dispatchEvent(new Event('scroll')); + expect(result!.directions.right).toBeTruthy(); + expect(result!.directions.bottom).toBeTruthy(); + expect(result!.directions.left).toBeFalsy(); + expect(result!.directions.top).toBeFalsy(); + + setScroll(10, 20); + globalThis.dispatchEvent(new Event('scroll')); + expect(result!.directions.left).toBeTruthy(); + expect(result!.directions.top).toBeTruthy(); + expect(result!.directions.right).toBeFalsy(); + expect(result!.directions.bottom).toBeFalsy(); + + scope.stop(); + }); + + it('reports arrivedState at the top/left edges initially', () => { + const scope = effectScope(); + let result: ReturnType<typeof useWindowScroll>; + scope.run(() => { + result = useWindowScroll(); + }); + + expect(result!.arrivedState.top).toBeTruthy(); + expect(result!.arrivedState.left).toBeTruthy(); + scope.stop(); + }); + + it('clears arrivedState.top once scrolled down', () => { + const scope = effectScope(); + let result: ReturnType<typeof useWindowScroll>; + scope.run(() => { + result = useWindowScroll(); + }); + + setScroll(0, 100); + globalThis.dispatchEvent(new Event('scroll')); + expect(result!.arrivedState.top).toBeFalsy(); + scope.stop(); + }); + + it('honors the top offset for arrivedState', () => { + const scope = effectScope(); + let result: ReturnType<typeof useWindowScroll>; + scope.run(() => { + result = useWindowScroll({ offset: { top: 30 } }); + }); + + setScroll(0, 20); + globalThis.dispatchEvent(new Event('scroll')); + // Within the 30px offset, still considered "arrived at top". + expect(result!.arrivedState.top).toBeTruthy(); + + setScroll(0, 40); + globalThis.dispatchEvent(new Event('scroll')); + expect(result!.arrivedState.top).toBeFalsy(); + scope.stop(); + }); + + it('measure() recomputes state without a scroll event', () => { + const scope = effectScope(); + let result: ReturnType<typeof useWindowScroll>; + scope.run(() => { + result = useWindowScroll(); + }); + + setScroll(0, 70); + // No event dispatched; values are stale until measure(). + expect(result!.y.value).toBe(0); + result!.measure(); + expect(result!.y.value).toBe(70); + scope.stop(); + }); + + it('is SSR-safe when window is undefined', () => { + const scope = effectScope(); + let result: ReturnType<typeof useWindowScroll>; + scope.run(() => { + result = useWindowScroll({ window: undefined }); + }); + + expect(result!.x.value).toBe(0); + expect(result!.y.value).toBe(0); + expect(result!.isScrolling.value).toBeFalsy(); + // Writing should be a no-op (no throw). + expect(() => { + result!.x.value = 10; + }).not.toThrow(); + scope.stop(); + }); + + it('throttles the scroll handler when throttle is set', async () => { + vi.useFakeTimers(); + const onScroll = vi.fn(); + const scope = effectScope(); + scope.run(() => { + useWindowScroll({ throttle: 100, onScroll }); + }); + + setScroll(1, 1); + globalThis.dispatchEvent(new Event('scroll')); + setScroll(2, 2); + globalThis.dispatchEvent(new Event('scroll')); + setScroll(3, 3); + globalThis.dispatchEvent(new Event('scroll')); + + // Trailing-only throttle: collapses the burst into a single deferred call. + vi.advanceTimersByTime(120); + await nextTick(); + expect(onScroll).toHaveBeenCalledTimes(1); + + scope.stop(); + vi.useRealTimers(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useWindowScroll/index.ts b/vue/toolkit/src/composables/browser/useWindowScroll/index.ts new file mode 100644 index 0000000..7839059 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useWindowScroll/index.ts @@ -0,0 +1,272 @@ +import { computed, reactive, shallowRef, toValue } from 'vue'; +import type { MaybeRefOrGetter, Reactive, ShallowRef, WritableComputedRef } from 'vue'; +import { noop } from '@robonen/stdlib'; +import { defaultWindow } from '@/types'; +import type { ConfigurableWindow } from '@/types'; +import { useEventListener } from '@/composables/browser/useEventListener'; +import { useDebounceFn } from '@/composables/utilities/useDebounceFn'; +import { useThrottleFn } from '@/composables/utilities/useThrottleFn'; +import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted'; + +/** + * `scrollTop`/`scrollLeft` are sub-pixel (fractional) numbers, while + * `scrollHeight`/`scrollWidth` and `clientHeight`/`clientWidth` are rounded + * integers. We therefore allow a 1px tolerance when deciding whether an edge + * has been reached. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#determine_if_an_element_has_been_totally_scrolled + */ +const ARRIVED_STATE_THRESHOLD_PIXELS = 1; + +export interface UseWindowScrollOffset { + left?: number; + right?: number; + top?: number; + bottom?: number; +} + +export interface UseWindowScrollEdgeState { + left: boolean; + right: boolean; + top: boolean; + bottom: boolean; +} + +export interface UseWindowScrollOptions extends ConfigurableWindow { + /** + * Throttle time (ms) for the scroll handler. Disabled by default. + * + * @default 0 + */ + throttle?: number; + + /** + * Delay (ms) after the last scroll event before `isScrolling` flips back to + * `false`. When `throttle` is set the effective idle window becomes + * `throttle + idle`. + * + * @default 200 + */ + idle?: number; + + /** + * Offset the `arrivedState` edges by a number of pixels, e.g. to treat the + * page as "arrived at bottom" slightly before the true bottom. + */ + offset?: UseWindowScrollOffset; + + /** + * Invoked on every (throttled) scroll event. + */ + onScroll?: (event: Event) => void; + + /** + * Invoked once scrolling stops (after the idle window elapses). + */ + onStop?: (event: Event) => void; + + /** + * Listener options for the scroll event. + * + * @default { capture: false, passive: true } + */ + eventListenerOptions?: boolean | AddEventListenerOptions; + + /** + * Scroll behavior applied when writing to `x`/`y`. `'auto'` jumps instantly, + * `'smooth'` animates. Accepts a ref or getter for reactivity. + * + * @default 'auto' + */ + behavior?: MaybeRefOrGetter<ScrollBehavior>; +} + +export interface UseWindowScrollReturn { + /** + * Reactive horizontal scroll position. Writing to it scrolls the window. + */ + x: WritableComputedRef<number>; + + /** + * Reactive vertical scroll position. Writing to it scrolls the window. + */ + y: WritableComputedRef<number>; + + /** + * Whether the window is currently being scrolled. + */ + isScrolling: ShallowRef<boolean>; + + /** + * Whether each edge of the document has been reached. + */ + arrivedState: Reactive<UseWindowScrollEdgeState>; + + /** + * The direction(s) the window is currently scrolling towards. + */ + directions: Reactive<UseWindowScrollEdgeState>; + + /** + * Force a re-measurement of `arrivedState`/`directions`. + */ + measure: () => void; +} + +/** + * @name useWindowScroll + * @category Browser + * @description Reactive window scroll position with arrived/direction tracking. Writing to `x`/`y` scrolls the window. + * + * @param {UseWindowScrollOptions} [options={}] Options + * @returns {UseWindowScrollReturn} Reactive `x`, `y`, `isScrolling`, `arrivedState`, `directions` and a `measure()` helper + * + * @example + * const { x, y, isScrolling, arrivedState, directions } = useWindowScroll(); + * + * @since 0.0.15 + */ +export function useWindowScroll(options: UseWindowScrollOptions = {}): UseWindowScrollReturn { + const { + window = defaultWindow, + throttle = 0, + idle = 200, + onStop = noop, + onScroll = noop, + offset = {}, + eventListenerOptions = { capture: false, passive: true }, + behavior = 'auto', + } = options; + + const internalX = shallowRef(0); + const internalY = shallowRef(0); + + // We use computed getters/setters so that writing `x`/`y` triggers a real + // `scrollTo()` while the internal refs are updated from the scroll event + // without re-triggering a scroll. + const x = computed<number>({ + get: () => internalX.value, + set: value => scrollTo(value, undefined), + }); + + const y = computed<number>({ + get: () => internalY.value, + set: value => scrollTo(undefined, value), + }); + + function scrollTo(_x: number | undefined, _y: number | undefined): void { + if (!window) + return; + + window.scrollTo({ + left: _x ?? internalX.value, + top: _y ?? internalY.value, + behavior: toValue(behavior), + }); + + if (_x !== null && _x !== undefined) + internalX.value = _x; + if (_y !== null && _y !== undefined) + internalY.value = _y; + } + + const isScrolling = shallowRef(false); + + const arrivedState = reactive<UseWindowScrollEdgeState>({ + left: true, + right: false, + top: true, + bottom: false, + }); + + const directions = reactive<UseWindowScrollEdgeState>({ + left: false, + right: false, + top: false, + bottom: false, + }); + + function setArrivedState(): void { + if (!window) + return; + + const el = window.document.documentElement; + const { direction } = window.getComputedStyle(el); + const directionMultiplier = direction === 'rtl' ? -1 : 1; + + const scrollLeft = window.scrollX; + directions.left = scrollLeft < internalX.value; + directions.right = scrollLeft > internalX.value; + + arrivedState.left = Math.abs(scrollLeft * directionMultiplier) <= (offset.left ?? 0); + arrivedState.right = Math.abs(scrollLeft * directionMultiplier) + + el.clientWidth >= el.scrollWidth + - (offset.right ?? 0) + - ARRIVED_STATE_THRESHOLD_PIXELS; + + internalX.value = scrollLeft; + + const scrollTop = window.scrollY; + directions.top = scrollTop < internalY.value; + directions.bottom = scrollTop > internalY.value; + + arrivedState.top = Math.abs(scrollTop) <= (offset.top ?? 0); + arrivedState.bottom = Math.abs(scrollTop) + + el.clientHeight >= el.scrollHeight + - (offset.bottom ?? 0) + - ARRIVED_STATE_THRESHOLD_PIXELS; + + internalY.value = scrollTop; + } + + function onScrollEnd(event: Event): void { + // Dedupe in case the native `scrollend` event is supported. + if (!isScrolling.value) + return; + + isScrolling.value = false; + directions.left = false; + directions.right = false; + directions.top = false; + directions.bottom = false; + onStop(event); + } + + const onScrollEndDebounced = useDebounceFn(onScrollEnd, throttle + idle); + + function onScrollHandler(event: Event): void { + if (!window) + return; + + setArrivedState(); + + isScrolling.value = true; + onScrollEndDebounced(event); + onScroll(event); + } + + useEventListener( + window, + 'scroll', + throttle ? useThrottleFn(onScrollHandler, throttle, true, false) : onScrollHandler, + eventListenerOptions, + ); + + useEventListener( + window, + 'scrollend', + onScrollEnd, + eventListenerOptions, + ); + + tryOnMounted(setArrivedState); + + return { + x, + y, + isScrolling, + arrivedState, + directions, + measure: setArrivedState, + }; +} diff --git a/vue/toolkit/src/composables/browser/useWindowSize/index.test.ts b/vue/toolkit/src/composables/browser/useWindowSize/index.test.ts new file mode 100644 index 0000000..9d60cf7 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useWindowSize/index.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, nextTick } from 'vue'; +import { useWindowSize } from '.'; + +describe(useWindowSize, () => { + beforeEach(() => { + vi.stubGlobal('matchMedia', undefined); + window.innerWidth = 1024; + window.innerHeight = 768; + }); + afterEach(() => vi.unstubAllGlobals()); + + it('reads the current window size', () => { + const scope = effectScope(); + let size: ReturnType<typeof useWindowSize>; + scope.run(() => { + size = useWindowSize({ listenOrientation: false }); + }); + + expect(size!.width.value).toBe(1024); + expect(size!.height.value).toBe(768); + scope.stop(); + }); + + it('updates on resize', async () => { + const scope = effectScope(); + let size: ReturnType<typeof useWindowSize>; + scope.run(() => { + size = useWindowSize({ listenOrientation: false }); + }); + + window.innerWidth = 500; + window.innerHeight = 400; + globalThis.dispatchEvent(new Event('resize')); + await nextTick(); + + expect(size!.width.value).toBe(500); + expect(size!.height.value).toBe(400); + scope.stop(); + }); + + it('uses documentElement client size when includeScrollbar is false', () => { + Object.defineProperty(globalThis.document.documentElement, 'clientWidth', { + configurable: true, + value: 1000, + }); + Object.defineProperty(globalThis.document.documentElement, 'clientHeight', { + configurable: true, + value: 700, + }); + + const scope = effectScope(); + let size: ReturnType<typeof useWindowSize>; + scope.run(() => { + size = useWindowSize({ listenOrientation: false, includeScrollbar: false }); + }); + + expect(size!.width.value).toBe(1000); + expect(size!.height.value).toBe(700); + scope.stop(); + }); + + it('reads outer window size for type "outer"', () => { + window.outerWidth = 1440; + window.outerHeight = 900; + + const scope = effectScope(); + let size: ReturnType<typeof useWindowSize>; + scope.run(() => { + size = useWindowSize({ listenOrientation: false, type: 'outer' }); + }); + + expect(size!.width.value).toBe(1440); + expect(size!.height.value).toBe(900); + scope.stop(); + }); + + it('reads scaled visual viewport size for type "visual"', async () => { + const visualViewport = { + width: 800, + height: 600, + scale: 1.5, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + Object.defineProperty(globalThis, 'visualViewport', { + configurable: true, + value: visualViewport, + }); + + const scope = effectScope(); + let size: ReturnType<typeof useWindowSize>; + scope.run(() => { + size = useWindowSize({ listenOrientation: false, type: 'visual' }); + }); + await nextTick(); + + expect(size!.width.value).toBe(1200); + expect(size!.height.value).toBe(900); + expect(visualViewport.addEventListener).toHaveBeenCalledWith('resize', expect.any(Function), expect.objectContaining({ passive: true })); + scope.stop(); + + Object.defineProperty(globalThis, 'visualViewport', { configurable: true, value: undefined }); + }); + + it('falls back to inner size for type "visual" without visualViewport', () => { + Object.defineProperty(globalThis, 'visualViewport', { configurable: true, value: undefined }); + + const scope = effectScope(); + let size: ReturnType<typeof useWindowSize>; + scope.run(() => { + size = useWindowSize({ listenOrientation: false, type: 'visual' }); + }); + + expect(size!.width.value).toBe(1024); + expect(size!.height.value).toBe(768); + scope.stop(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useWindowSize/index.ts b/vue/toolkit/src/composables/browser/useWindowSize/index.ts new file mode 100644 index 0000000..0a225d8 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useWindowSize/index.ts @@ -0,0 +1,135 @@ +import { shallowRef, watch } from 'vue'; +import type { ShallowRef } from 'vue'; +import { defaultWindow } from '@/types'; +import type { ConfigurableWindow } from '@/types'; +import { useEventListener } from '@/composables/browser/useEventListener'; +import { useMediaQuery } from '@/composables/browser/useMediaQuery'; +import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted'; + +/** + * Which window dimensions to track. + * + * - `'inner'` — `window.innerWidth/innerHeight` (or `documentElement.clientWidth/clientHeight` + * when `includeScrollbar` is `false`). The viewport size. + * - `'outer'` — `window.outerWidth/outerHeight`. The whole browser window, including chrome. + * - `'visual'` — `window.visualViewport` size, accounting for pinch-zoom scale. Useful on + * mobile where the visual viewport differs from the layout viewport. + */ +export type WindowSizeType = 'inner' | 'outer' | 'visual'; + +export interface UseWindowSizeOptions extends ConfigurableWindow { + /** + * The initial width, used before the window is available (e.g. during SSR). + * + * @default Number.POSITIVE_INFINITY + */ + initialWidth?: number; + + /** + * The initial height, used before the window is available (e.g. during SSR). + * + * @default Number.POSITIVE_INFINITY + */ + initialHeight?: number; + + /** + * Listen to orientation changes via a `(orientation: portrait)` media query. + * + * @default true + */ + listenOrientation?: boolean; + + /** + * Use `window.innerWidth/innerHeight` (includes scrollbar) instead of + * `documentElement.clientWidth/clientHeight`. Only affects the `'inner'` type. + * + * @default true + */ + includeScrollbar?: boolean; + + /** + * Which window dimensions to track. + * + * @default 'inner' + */ + type?: WindowSizeType; +} + +export interface UseWindowSizeReturn { + width: ShallowRef<number>; + height: ShallowRef<number>; +} + +/** + * @name useWindowSize + * @category Browser + * @description Reactive window size. Tracks the inner viewport, the outer window, or the + * visual viewport (pinch-zoom aware), and reacts to resize and orientation changes. + * + * @param {UseWindowSizeOptions} [options={}] Options + * @returns {UseWindowSizeReturn} Reactive `width` and `height` + * + * @example + * const { width, height } = useWindowSize(); + * + * @example + * // Track the pinch-zoom aware visual viewport on mobile + * const { width, height } = useWindowSize({ type: 'visual' }); + * + * @since 0.0.15 + */ +export function useWindowSize(options: UseWindowSizeOptions = {}): UseWindowSizeReturn { + const { + window = defaultWindow, + initialWidth = Number.POSITIVE_INFINITY, + initialHeight = Number.POSITIVE_INFINITY, + listenOrientation = true, + includeScrollbar = true, + type = 'inner', + } = options; + + const width = shallowRef(initialWidth); + const height = shallowRef(initialHeight); + + const update = (): void => { + if (!window) + return; + + if (type === 'outer') { + width.value = window.outerWidth; + height.value = window.outerHeight; + } + else if (type === 'visual' && window.visualViewport) { + const { width: visualWidth, height: visualHeight, scale } = window.visualViewport; + width.value = Math.round(visualWidth * scale); + height.value = Math.round(visualHeight * scale); + } + else if (includeScrollbar) { + width.value = window.innerWidth; + height.value = window.innerHeight; + } + else { + width.value = window.document.documentElement.clientWidth; + height.value = window.document.documentElement.clientHeight; + } + }; + + update(); + tryOnMounted(update); + + const listenerOptions = { passive: true } as const; + + useEventListener('resize', update, listenerOptions); + + // Reactive getter target: auto-binds when `visualViewport` becomes available and + // is a no-op otherwise (SSR / unsupported), without recreating listeners. + if (type === 'visual') + useEventListener(() => window?.visualViewport, 'resize', update, listenerOptions); + + if (listenOrientation) { + const orientation = useMediaQuery('(orientation: portrait)', { window }); + watch(orientation, update); + } + + return { width, height }; +} diff --git a/vue/toolkit/src/composables/debug/useRenderInfo/index.test.ts b/vue/toolkit/src/composables/debug/useRenderInfo/index.test.ts index 6208852..d201e4b 100644 --- a/vue/toolkit/src/composables/debug/useRenderInfo/index.test.ts +++ b/vue/toolkit/src/composables/debug/useRenderInfo/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { useRenderInfo } from '.'; import { defineComponent, nextTick, ref } from 'vue'; import { mount } from '@vue/test-utils'; diff --git a/vue/toolkit/src/composables/lifecycle/tryOnBeforeMount/index.ts b/vue/toolkit/src/composables/lifecycle/tryOnBeforeMount/index.ts index b7fc3e4..ca2d9d1 100644 --- a/vue/toolkit/src/composables/lifecycle/tryOnBeforeMount/index.ts +++ b/vue/toolkit/src/composables/lifecycle/tryOnBeforeMount/index.ts @@ -1,4 +1,4 @@ -import { onBeforeMount, nextTick } from 'vue'; +import { nextTick, onBeforeMount } from 'vue'; import type { ComponentInternalInstance } from 'vue'; import { getLifeCycleTarger } from '@/utils'; import type { VoidFunction } from '@robonen/stdlib'; diff --git a/vue/toolkit/src/composables/lifecycle/tryOnMounted/index.test.ts b/vue/toolkit/src/composables/lifecycle/tryOnMounted/index.test.ts index 49c912e..507ef72 100644 --- a/vue/toolkit/src/composables/lifecycle/tryOnMounted/index.test.ts +++ b/vue/toolkit/src/composables/lifecycle/tryOnMounted/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, vi, expect } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { defineComponent, nextTick } from 'vue'; import type { PropType } from 'vue'; import { tryOnMounted } from '.'; diff --git a/vue/toolkit/src/composables/lifecycle/tryOnMounted/index.ts b/vue/toolkit/src/composables/lifecycle/tryOnMounted/index.ts index 820198b..e9b133d 100644 --- a/vue/toolkit/src/composables/lifecycle/tryOnMounted/index.ts +++ b/vue/toolkit/src/composables/lifecycle/tryOnMounted/index.ts @@ -1,10 +1,8 @@ -import { onMounted, nextTick } from 'vue'; +import { nextTick, onMounted } from 'vue'; import type { ComponentInternalInstance } from 'vue'; import { getLifeCycleTarger } from '@/utils'; import type { VoidFunction } from '@robonen/stdlib'; -// TODO: tests - export interface TryOnMountedOptions { sync?: boolean; target?: ComponentInternalInstance; diff --git a/vue/toolkit/src/composables/math/useClamp/index.test.ts b/vue/toolkit/src/composables/math/useClamp/index.test.ts index 87f9aa6..b334438 100644 --- a/vue/toolkit/src/composables/math/useClamp/index.test.ts +++ b/vue/toolkit/src/composables/math/useClamp/index.test.ts @@ -1,5 +1,5 @@ -import { ref, readonly, computed } from 'vue'; -import { describe, it, expect } from 'vitest'; +import { computed, readonly, ref } from 'vue'; +import { describe, expect, it } from 'vitest'; import { useClamp } from '.'; describe(useClamp, () => { diff --git a/vue/toolkit/src/composables/reactivity/broadcastedRef/index.test.ts b/vue/toolkit/src/composables/reactivity/broadcastedRef/index.test.ts index 1aaedaa..224d7f2 100644 --- a/vue/toolkit/src/composables/reactivity/broadcastedRef/index.test.ts +++ b/vue/toolkit/src/composables/reactivity/broadcastedRef/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { defineComponent, effectScope, nextTick, watch } from 'vue'; import { mount } from '@vue/test-utils'; import { broadcastedRef } from '.'; diff --git a/vue/toolkit/src/composables/reactivity/index.ts b/vue/toolkit/src/composables/reactivity/index.ts index a17f1fc..a24824e 100644 --- a/vue/toolkit/src/composables/reactivity/index.ts +++ b/vue/toolkit/src/composables/reactivity/index.ts @@ -1,4 +1,22 @@ export * from './broadcastedRef'; +export * from './refAutoReset'; +export * from './refDebounced'; +export * from './refThrottled'; +export * from './until'; +export * from './useArrayFilter'; +export * from './useArrayFind'; +export * from './useArrayMap'; export * from './useCached'; +export * from './useCloned'; +export * from './useCycleList'; export * from './useLastChanged'; +export * from './usePrevious'; export * from './useSyncRefs'; +export * from './useToNumber'; +export * from './useToString'; +export * from './watchDebounced'; +export * from './watchIgnorable'; +export * from './watchOnce'; +export * from './watchPausable'; +export * from './watchThrottled'; +export * from './whenever'; diff --git a/vue/toolkit/src/composables/reactivity/refAutoReset/index.test.ts b/vue/toolkit/src/composables/reactivity/refAutoReset/index.test.ts new file mode 100644 index 0000000..fa7166c --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/refAutoReset/index.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, nextTick, ref, watch } from 'vue'; +import { refAutoReset } from '.'; + +describe(refAutoReset, () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('initializes with the default value', () => { + const value = refAutoReset('default', 1000); + expect(value.value).toBe('default'); + }); + + it('resets to the default value after the delay', () => { + const value = refAutoReset('default', 1000); + + value.value = 'changed'; + expect(value.value).toBe('changed'); + + vi.advanceTimersByTime(999); + expect(value.value).toBe('changed'); + + vi.advanceTimersByTime(1); + expect(value.value).toBe('default'); + }); + + it('uses a default delay of 10000ms', () => { + const value = refAutoReset('default'); + + value.value = 'changed'; + vi.advanceTimersByTime(9999); + expect(value.value).toBe('changed'); + + vi.advanceTimersByTime(1); + expect(value.value).toBe('default'); + }); + + it('restarts the timer on each set', () => { + const value = refAutoReset('default', 1000); + + value.value = 'first'; + vi.advanceTimersByTime(800); + + value.value = 'second'; + vi.advanceTimersByTime(800); + // 1600ms total elapsed but only 800ms since last set + expect(value.value).toBe('second'); + + vi.advanceTimersByTime(200); + expect(value.value).toBe('default'); + }); + + it('does not reset before any write', () => { + const value = refAutoReset('default', 1000); + + vi.advanceTimersByTime(5000); + expect(value.value).toBe('default'); + }); + + it('resolves a reactive default value at reset time', () => { + const fallback = ref('a'); + const value = refAutoReset(fallback, 1000); + + expect(value.value).toBe('a'); + + value.value = 'changed'; + fallback.value = 'b'; + + vi.advanceTimersByTime(1000); + expect(value.value).toBe('b'); + }); + + it('resolves a reactive delay on each set', () => { + const delay = ref(1000); + const value = refAutoReset('default', delay); + + value.value = 'first'; + delay.value = 500; + + // delay is resolved at set time -> still 1000 for the first write + vi.advanceTimersByTime(1000); + expect(value.value).toBe('default'); + + // next set picks up the new delay + value.value = 'second'; + vi.advanceTimersByTime(500); + expect(value.value).toBe('default'); + }); + + it('resolves a getter as the default value', () => { + let base = 1; + const value = refAutoReset(() => base, 1000); + + expect(value.value).toBe(1); + value.value = 99; + base = 5; + + vi.advanceTimersByTime(1000); + expect(value.value).toBe(5); + }); + + it('is reactive and triggers watchers on set and on reset', async () => { + const scope = effectScope(); + const spy = vi.fn(); + + scope.run(() => { + const value = refAutoReset('default', 1000); + watch(value, spy, { flush: 'sync' }); + value.value = 'changed'; + }); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenLastCalledWith('changed', 'default', expect.anything()); + + vi.advanceTimersByTime(1000); + await nextTick(); + + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenLastCalledWith('default', 'changed', expect.anything()); + + scope.stop(); + }); + + it('cancels the pending reset when the owning scope is disposed', () => { + const scope = effectScope(); + let value!: ReturnType<typeof refAutoReset<string>>; + + scope.run(() => { + value = refAutoReset('default', 1000); + value.value = 'changed'; + }); + + scope.stop(); + + vi.advanceTimersByTime(1000); + expect(value.value).toBe('changed'); + }); +}); diff --git a/vue/toolkit/src/composables/reactivity/refAutoReset/index.ts b/vue/toolkit/src/composables/reactivity/refAutoReset/index.ts new file mode 100644 index 0000000..a527609 --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/refAutoReset/index.ts @@ -0,0 +1,59 @@ +import { customRef, toValue } from 'vue'; +import type { MaybeRefOrGetter, Ref } from 'vue'; +import { useTimeoutFn } from '@/composables/utilities/useTimeoutFn'; + +export type RefAutoResetReturn<T> = Ref<T>; + +/** + * @name refAutoReset + * @category Reactivity + * @description Create a ref that resets to its default value after a delay + * since the last write. Each set restarts the timer; reading is reactive. + * + * @param {MaybeRefOrGetter<T>} defaultValue The value the ref resets to (resolved each reset, can be reactive) + * @param {MaybeRefOrGetter<number>} [afterMs=10000] Delay in milliseconds before resetting (resolved on each set, can be reactive) + * @returns {RefAutoResetReturn<T>} A ref that auto-resets to `defaultValue` + * + * @example + * const message = refAutoReset('', 1000); + * message.value = 'Saved!'; // reverts to '' after 1000ms + * + * @example + * // Reactive delay and default + * const delay = ref(2000); + * const fallback = ref('idle'); + * const status = refAutoReset(fallback, delay); + * + * @since 0.0.15 + */ +export function refAutoReset<T>( + defaultValue: MaybeRefOrGetter<T>, + afterMs: MaybeRefOrGetter<number> = 10000, +): RefAutoResetReturn<T> { + return customRef<T>((track, trigger) => { + let value: T = toValue(defaultValue); + + const { start, stop } = useTimeoutFn( + () => { + value = toValue(defaultValue); + trigger(); + }, + afterMs, + { immediate: false }, + ); + + return { + get() { + track(); + return value; + }, + set(newValue) { + value = newValue; + trigger(); + + stop(); + start(); + }, + }; + }); +} diff --git a/vue/toolkit/src/composables/reactivity/refDebounced/index.test.ts b/vue/toolkit/src/composables/reactivity/refDebounced/index.test.ts new file mode 100644 index 0000000..5b55756 --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/refDebounced/index.test.ts @@ -0,0 +1,153 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, isReadonly, nextTick, reactive, ref } from 'vue'; +import { refDebounced } from '.'; + +describe(refDebounced, () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it('returns a readonly ref', () => { + const source = ref('a'); + const debounced = refDebounced(source); + expect(isReadonly(debounced)).toBeTruthy(); + }); + + it('mirrors the initial value synchronously', () => { + const source = ref('initial'); + const debounced = refDebounced(source, 100); + expect(debounced.value).toBe('initial'); + }); + + it('delays updates by the given ms', async () => { + const source = ref('a'); + const debounced = refDebounced(source, 100); + + source.value = 'b'; + await nextTick(); + expect(debounced.value).toBe('a'); + + vi.advanceTimersByTime(99); + expect(debounced.value).toBe('a'); + + vi.advanceTimersByTime(1); + expect(debounced.value).toBe('b'); + }); + + it('uses a default delay of 200ms', async () => { + const source = ref(0); + const debounced = refDebounced(source); + + source.value = 1; + await nextTick(); + + vi.advanceTimersByTime(199); + expect(debounced.value).toBe(0); + + vi.advanceTimersByTime(1); + expect(debounced.value).toBe(1); + }); + + it('coalesces rapid bursts into a single trailing update', async () => { + const source = ref(0); + const debounced = refDebounced(source, 100); + + source.value = 1; + await nextTick(); + vi.advanceTimersByTime(50); + + source.value = 2; + await nextTick(); + vi.advanceTimersByTime(50); + + source.value = 3; + await nextTick(); + + // Still within the debounce window — no update yet. + expect(debounced.value).toBe(0); + + vi.advanceTimersByTime(100); + expect(debounced.value).toBe(3); + }); + + it('respects maxWait to force progress under sustained input', async () => { + const source = ref(0); + const debounced = refDebounced(source, 100, { maxWait: 250 }); + + // Keep pushing updates just before each debounce timer fires. + for (let i = 1; i <= 5; i++) { + source.value = i; + await nextTick(); + vi.advanceTimersByTime(60); + } + + // 5 * 60 = 300ms elapsed; maxWait (250ms) must have forced a flush. + expect(debounced.value).not.toBe(0); + }); + + it('supports a reactive ms', async () => { + const source = ref('a'); + const ms = ref(100); + const debounced = refDebounced(source, ms); + + ms.value = 50; + source.value = 'b'; + await nextTick(); + + vi.advanceTimersByTime(50); + expect(debounced.value).toBe('b'); + }); + + it('runs synchronously when ms is zero or negative', async () => { + const source = ref('a'); + const debounced = refDebounced(source, 0); + + source.value = 'b'; + await nextTick(); + expect(debounced.value).toBe('b'); + }); + + it('works with a getter source', async () => { + const state = reactive({ n: 1 }); + const debounced = refDebounced(() => state.n, 100); + + expect(debounced.value).toBe(1); + + state.n = 2; + await nextTick(); + vi.advanceTimersByTime(100); + expect(debounced.value).toBe(2); + }); + + it('disposes pending timers when the owning scope stops', async () => { + const source = ref('a'); + let debounced: ReturnType<typeof refDebounced<string>>; + + const scope = effectScope(); + scope.run(() => { + debounced = refDebounced(source, 100); + }); + + source.value = 'b'; + await nextTick(); + + scope.stop(); + + vi.advanceTimersByTime(200); + // The pending update was cancelled with the scope. + expect(debounced!.value).toBe('a'); + }); + + it('does not update when the source never changes (SSR-safe, no timers)', () => { + const source = ref('stable'); + const debounced = refDebounced(source, 100); + + vi.advanceTimersByTime(1000); + expect(debounced.value).toBe('stable'); + }); +}); diff --git a/vue/toolkit/src/composables/reactivity/refDebounced/index.ts b/vue/toolkit/src/composables/reactivity/refDebounced/index.ts new file mode 100644 index 0000000..ea3dac2 --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/refDebounced/index.ts @@ -0,0 +1,52 @@ +import { shallowReadonly, shallowRef, toRef, toValue, watch } from 'vue'; +import type { MaybeRefOrGetter, Ref } from 'vue'; +import { useDebounceFn } from '@/composables/utilities/useDebounceFn'; +import type { UseDebounceFnOptions } from '@/composables/utilities/useDebounceFn'; + +export type RefDebouncedOptions = UseDebounceFnOptions; + +export type RefDebouncedReturn<T> = Readonly<Ref<T>>; + +/** + * @name refDebounced + * @category Reactivity + * @description A readonly ref whose value mirrors a source but only after + * updates stop arriving for `ms`. Wraps the source change in our debounce + * primitive (built on `debounceFilter`), so rapid bursts collapse into a single + * delayed write. Supports a `maxWait` ceiling so the value still progresses + * under sustained input, and tears its timer down with the owning scope. + * + * @param {MaybeRefOrGetter<T>} source The ref, getter, or reactive source to debounce + * @param {MaybeRefOrGetter<number>} [ms=200] Debounce delay in milliseconds (can be reactive) + * @param {RefDebouncedOptions} [options={}] Debounce options (`maxWait`, `rejectOnCancel`) + * @returns {RefDebouncedReturn<T>} A readonly ref tracking the source with debounced updates + * + * @example + * const input = ref(''); + * const debounced = refDebounced(input, 300); + * // debounced.value lags `input` by 300ms of quiet + * + * @example + * // Guarantee the debounced value advances at least every 1000ms + * const debounced = refDebounced(input, 300, { maxWait: 1000 }); + * + * @since 0.0.15 + */ +export function refDebounced<T>( + source: MaybeRefOrGetter<T>, + ms: MaybeRefOrGetter<number> = 200, + options: RefDebouncedOptions = {}, +): RefDebouncedReturn<T> { + const reference = toRef(source); + const debounced = shallowRef(toValue(source)) as Ref<T>; + + const update = useDebounceFn(() => { + debounced.value = reference.value; + }, ms, options); + + watch(reference, () => { + void update(); + }); + + return shallowReadonly(debounced) as RefDebouncedReturn<T>; +} diff --git a/vue/toolkit/src/composables/reactivity/refThrottled/index.test.ts b/vue/toolkit/src/composables/reactivity/refThrottled/index.test.ts new file mode 100644 index 0000000..877a7e8 --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/refThrottled/index.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, nextTick, ref } from 'vue'; +import { refThrottled } from '.'; + +describe(refThrottled, () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('initializes with the source value', () => { + const scope = effectScope(); + scope.run(() => { + const source = ref('init'); + const throttled = refThrottled(source, 100); + expect(throttled.value).toBe('init'); + }); + scope.stop(); + }); + + it('updates immediately on the leading edge', async () => { + const scope = effectScope(); + await scope.run(async () => { + const source = ref(0); + const throttled = refThrottled(source, 100); + + source.value = 1; + await nextTick(); + expect(throttled.value).toBe(1); + }); + scope.stop(); + }); + + it('throttles intermediate updates within the window', async () => { + const scope = effectScope(); + await scope.run(async () => { + const source = ref(0); + const throttled = refThrottled(source, 100); + + source.value = 1; + await nextTick(); + expect(throttled.value).toBe(1); + + source.value = 2; + await nextTick(); + // Still within the window: not yet propagated as a fresh leading update. + expect(throttled.value).toBe(1); + + source.value = 3; + await nextTick(); + expect(throttled.value).toBe(1); + + // Trailing edge fires with the most recent value. + vi.advanceTimersByTime(100); + await nextTick(); + expect(throttled.value).toBe(3); + }); + scope.stop(); + }); + + it('does not emit a trailing update when trailing is false', async () => { + const scope = effectScope(); + await scope.run(async () => { + const source = ref(0); + const throttled = refThrottled(source, 100, false); + + source.value = 1; + await nextTick(); + expect(throttled.value).toBe(1); + + source.value = 2; + await nextTick(); + expect(throttled.value).toBe(1); + + vi.advanceTimersByTime(200); + await nextTick(); + // No trailing edge: value stays at the leading update. + expect(throttled.value).toBe(1); + }); + scope.stop(); + }); + + it('skips the leading update when leading is false', async () => { + const scope = effectScope(); + await scope.run(async () => { + const source = ref(0); + const throttled = refThrottled(source, 100, true, false); + + source.value = 1; + await nextTick(); + // Leading suppressed: initial value retained until the trailing edge. + expect(throttled.value).toBe(0); + + vi.advanceTimersByTime(100); + await nextTick(); + expect(throttled.value).toBe(1); + }); + scope.stop(); + }); + + it('mirrors the source synchronously when delay <= 0', async () => { + const scope = effectScope(); + await scope.run(async () => { + const source = ref(0); + const throttled = refThrottled(source, 0); + + source.value = 1; + await nextTick(); + expect(throttled.value).toBe(1); + + source.value = 2; + await nextTick(); + expect(throttled.value).toBe(2); + }); + scope.stop(); + }); + + it('mirrors the source synchronously when delay is negative', async () => { + const scope = effectScope(); + await scope.run(async () => { + const source = ref('a'); + const throttled = refThrottled(source, -50); + + source.value = 'b'; + await nextTick(); + expect(throttled.value).toBe('b'); + }); + scope.stop(); + }); + + it('accepts a getter as the source', async () => { + const scope = effectScope(); + await scope.run(async () => { + const obj = ref({ n: 1 }); + const throttled = refThrottled(() => obj.value.n, 100); + expect(throttled.value).toBe(1); + + obj.value = { n: 2 }; + await nextTick(); + expect(throttled.value).toBe(2); + }); + scope.stop(); + }); + + it('reopens the leading edge after the window elapses', async () => { + const scope = effectScope(); + await scope.run(async () => { + const source = ref(0); + const throttled = refThrottled(source, 100); + + source.value = 1; + await nextTick(); + expect(throttled.value).toBe(1); + + // Advance past the window so the next change is a fresh leading update. + vi.advanceTimersByTime(100); + await nextTick(); + + source.value = 2; + await nextTick(); + expect(throttled.value).toBe(2); + }); + scope.stop(); + }); +}); diff --git a/vue/toolkit/src/composables/reactivity/refThrottled/index.ts b/vue/toolkit/src/composables/reactivity/refThrottled/index.ts new file mode 100644 index 0000000..bd5d4a0 --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/refThrottled/index.ts @@ -0,0 +1,60 @@ +import { ref, toValue, watch } from 'vue'; +import type { MaybeRefOrGetter, Ref } from 'vue'; +import { createFilterWrapper, throttleFilter } from '@/utils/filters'; + +export type RefThrottledReturn<T = any> = Ref<T>; + +/** + * @name refThrottled + * @category Reactivity + * @description A ref whose value updates are throttled. The returned ref mirrors + * the source but propagates changes at most once per `delay` window, making it + * useful for rate-limiting reactive updates driven by high-frequency events such + * as `scroll` or `resize`. + * + * @param {MaybeRefOrGetter<T>} source The ref, getter, or value to watch and throttle + * @param {number} [delay=200] A zero-or-greater delay in milliseconds; values around 100–250 (or higher) are most useful + * @param {boolean} [trailing=true] Update the value again on the trailing edge after the window elapses + * @param {boolean} [leading=true] Update the value on the leading edge of the window + * @returns {RefThrottledReturn<T>} A ref reflecting the throttled source value + * + * @example + * const input = ref(''); + * const throttled = refThrottled(input, 1000); + * + * @example + * const scrollY = ref(0); + * const throttledY = refThrottled(scrollY, 100, true, false); + * + * @since 0.0.15 + */ +export function refThrottled<T = any>( + source: MaybeRefOrGetter<T>, + delay = 200, + trailing = true, + leading = true, +): RefThrottledReturn<T> { + const throttled = ref(toValue(source)) as Ref<T>; + + // A non-positive delay disables throttling: mirror the source synchronously. + if (delay <= 0) { + watch(() => toValue(source), (value) => { + throttled.value = value; + }); + + return throttled; + } + + const update = createFilterWrapper( + throttleFilter(delay, trailing, leading), + () => { + throttled.value = toValue(source); + }, + ); + + watch(() => toValue(source), () => { + update(); + }); + + return throttled; +} diff --git a/vue/toolkit/src/composables/reactivity/until/index.test.ts b/vue/toolkit/src/composables/reactivity/until/index.test.ts new file mode 100644 index 0000000..de6dfd9 --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/until/index.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it, vi } from 'vitest'; +import { effectScope, nextTick, ref } from 'vue'; +import { until } from '.'; + +describe(until, () => { + it('resolves immediately when the value already matches', async () => { + const value = ref(7); + await expect(until(value).toBe(7)).resolves.toBe(7); + }); + + it('resolves once the value changes to match toBe', async () => { + const value = ref(0); + const promise = until(value).toBe(7); + + value.value = 3; + value.value = 7; + + await expect(promise).resolves.toBe(7); + }); + + it('tracks another ref passed to toBe', async () => { + const value = ref(0); + const target = ref(5); + const promise = until(value).toBe(target); + + value.value = 5; + await expect(promise).resolves.toBe(5); + }); + + it('resolves with a getter source watched against a literal', async () => { + const value = ref(0); + const promise = until(() => value.value * 2).toBe(18); + + value.value = 9; + await expect(promise).resolves.toBe(18); + }); + + it('resolves on toBeTruthy', async () => { + const value = ref(0); + const promise = until(value).toBeTruthy(); + + value.value = 1; + await expect(promise).resolves.toBe(1); + }); + + it('resolves on toBeNull', async () => { + const value = ref<number | null>(1); + const promise = until(value).toBeNull(); + + value.value = null; + await expect(promise).resolves.toBeNull(); + }); + + it('resolves on toBeUndefined', async () => { + const value = ref<number | undefined>(1); + const promise = until(value).toBeUndefined(); + + value.value = undefined; + await expect(promise).resolves.toBeUndefined(); + }); + + it('resolves on toBeNaN', async () => { + const value = ref(0); + const promise = until(value).toBeNaN(); + + value.value = Number.NaN; + await expect(promise).resolves.toBeNaN(); + }); + + it('resolves on toMatch with a predicate', async () => { + const value = ref(0); + const promise = until(value).toMatch(v => v > 10); + + value.value = 5; + value.value = 11; + await expect(promise).resolves.toBe(11); + }); + + it('negates a condition with not.toBe', async () => { + const value = ref(0); + const promise = until(value).not.toBe(0); + + value.value = 1; + await expect(promise).resolves.toBe(1); + }); + + it('negates toBeTruthy with not', async () => { + const value = ref(1); + const promise = until(value).not.toBeTruthy(); + + value.value = 0; + await expect(promise).resolves.toBe(0); + }); + + it('negates a tracked ref with not.toBe', async () => { + const value = ref(0); + const target = ref(0); + const promise = until(value).not.toBe(target); + + value.value = 4; + await expect(promise).resolves.toBe(4); + }); + + it('resolves after a single change with changed', async () => { + const value = ref(0); + const promise = until(value).changed(); + + value.value = 1; + await expect(promise).resolves.toBe(1); + }); + + it('resolves after n changes with changedTimes', async () => { + const value = ref(0); + const promise = until(value).changedTimes(3); + + value.value = 1; + value.value = 2; + value.value = 3; + await expect(promise).resolves.toBe(3); + }); + + it('works with array sources via toContains', async () => { + const list = ref<number[]>([1, 2]); + const promise = until(list).toContains(3); + + list.value = [1, 2, 3]; + await expect(promise).resolves.toEqual([1, 2, 3]); + }); + + it('negates toContains via not', async () => { + const list = ref<number[]>([1, 2, 3]); + const promise = until(list).not.toContains(3); + + list.value = [1, 2]; + await expect(promise).resolves.toEqual([1, 2]); + }); + + it('resolves with the current value on timeout when not throwing', async () => { + vi.useFakeTimers(); + try { + const value = ref(0); + const promise = until(value).toBe(99, { timeout: 100 }); + + vi.advanceTimersByTime(100); + await expect(promise).resolves.toBe(0); + } + finally { + vi.useRealTimers(); + } + }); + + it('rejects on timeout when throwOnTimeout is set', async () => { + vi.useFakeTimers(); + try { + const value = ref(0); + const promise = until(value).toBe(99, { timeout: 100, throwOnTimeout: true }); + // attach a catch synchronously so the rejection is observed + const assertion = expect(promise).rejects.toBe('Timeout'); + + await vi.advanceTimersByTimeAsync(100); + await assertion; + } + finally { + vi.useRealTimers(); + } + }); + + it('resolves before the timeout fires when condition is met', async () => { + vi.useFakeTimers(); + try { + const value = ref(0); + const promise = until(value).toBe(7, { timeout: 1000, throwOnTimeout: true }); + + value.value = 7; + await expect(promise).resolves.toBe(7); + } + finally { + vi.useRealTimers(); + } + }); + + it('stops watching after it resolves', async () => { + const value = ref(0); + const promise = until(value).toBe(1); + + value.value = 1; + await promise; + + // mutating further should not throw or re-trigger anything + value.value = 2; + value.value = 3; + expect(value.value).toBe(3); + }); + + it('does not leak a watcher into the owning scope', async () => { + const value = ref(0); + const scope = effectScope(); + + const promise = scope.run(() => until(value).toBe(1))!; + value.value = 1; + await expect(promise).resolves.toBe(1); + + scope.stop(); + }); + + it('supports a post flush timing', async () => { + const value = ref(0); + const promise = until(value).toBe(5, { flush: 'post' }); + + value.value = 5; + await nextTick(); + await expect(promise).resolves.toBe(5); + }); +}); diff --git a/vue/toolkit/src/composables/reactivity/until/index.ts b/vue/toolkit/src/composables/reactivity/until/index.ts new file mode 100644 index 0000000..6214012 --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/until/index.ts @@ -0,0 +1,247 @@ +import { isRef, nextTick, toValue, watch } from 'vue'; +import type { MaybeRefOrGetter, WatchOptions, WatchSource } from 'vue'; + +type ElementOf<T> = T extends Array<infer E> ? E : never; + +type Falsy = false | void | null | undefined | 0 | 0n | ''; + +export interface UntilToMatchOptions { + /** + * Milliseconds timeout for promise to resolve/reject if the when condition does not meet. + * 0 for never timed out + * + * @default 0 + */ + timeout?: number; + + /** + * Reject the promise when timeout + * + * @default false + */ + throwOnTimeout?: boolean; + + /** + * `flush` option for internal watch + * + * @default 'sync' + */ + flush?: WatchOptions['flush']; + + /** + * `deep` option for internal watch + * + * @default 'false' + */ + deep?: WatchOptions['deep']; +} + +export interface UntilBaseInstance<T, Not extends boolean = false> { + toMatch: (<U extends T = T>( + condition: (v: T) => v is U, + options?: UntilToMatchOptions, + ) => Not extends true ? Promise<Exclude<T, U>> : Promise<U>) & (( + condition: (v: T) => boolean, + options?: UntilToMatchOptions, + ) => Promise<T>); + changed: (options?: UntilToMatchOptions) => Promise<T>; + changedTimes: (n?: number, options?: UntilToMatchOptions) => Promise<T>; +} + +export interface UntilValueInstance<T, Not extends boolean = false> extends UntilBaseInstance<T, Not> { + readonly not: UntilValueInstance<T, Not extends true ? false : true>; + toBe: <P = T>(value: MaybeRefOrGetter<P>, options?: UntilToMatchOptions) => Not extends true ? Promise<T> : Promise<P>; + toBeTruthy: (options?: UntilToMatchOptions) => Not extends true ? Promise<T & Falsy> : Promise<Exclude<T, Falsy>>; + toBeNull: (options?: UntilToMatchOptions) => Not extends true ? Promise<Exclude<T, null>> : Promise<null>; + toBeUndefined: (options?: UntilToMatchOptions) => Not extends true ? Promise<Exclude<T, undefined>> : Promise<undefined>; + toBeNaN: (options?: UntilToMatchOptions) => Promise<T>; +} + +export interface UntilArrayInstance<T> extends UntilBaseInstance<T> { + readonly not: UntilArrayInstance<T>; + toContains: (value: MaybeRefOrGetter<ElementOf<T>>, options?: UntilToMatchOptions) => Promise<T>; +} + +function promiseTimeout(ms: number, throwOnTimeout = false, reason = 'Timeout'): Promise<void> { + return new Promise((resolve, reject) => { + if (throwOnTimeout) + setTimeout(() => reject(reason), ms); + else + setTimeout(resolve, ms); + }); +} + +function createUntil<T>(r: WatchSource<T> | MaybeRefOrGetter<T>, isNot = false): UntilValueInstance<T, boolean> | UntilArrayInstance<T> { + function toMatch( + condition: (v: T) => boolean, + { flush = 'sync', deep = false, timeout, throwOnTimeout }: UntilToMatchOptions = {}, + ): Promise<T> { + let stop: (() => void) | null = null; + const watcher = new Promise<T>((resolve) => { + stop = watch( + r as WatchSource<T>, + (v) => { + if (condition(v) !== isNot) { + if (stop) + stop(); + else + nextTick(() => stop?.()); + + resolve(v); + } + }, + { + flush, + deep, + immediate: true, + }, + ); + }); + + const promises = [watcher]; + if (timeout !== null && timeout !== undefined) { + promises.push( + promiseTimeout(timeout, throwOnTimeout) + .then(() => toValue(r as MaybeRefOrGetter<T>)) + .finally(() => stop?.()), + ); + } + + return Promise.race(promises); + } + + function toBe<P>(value: MaybeRefOrGetter<P | T>, options?: UntilToMatchOptions): Promise<T> { + if (!isRef(value)) + return toMatch(v => v === value, options); + + const { flush = 'sync', deep = false, timeout, throwOnTimeout } = options ?? {}; + let stop: (() => void) | null = null; + const watcher = new Promise<T>((resolve) => { + stop = watch( + [r as WatchSource<T>, value], + ([v1, v2]) => { + if (isNot !== (v1 === v2)) { + if (stop) + stop(); + else + nextTick(() => stop?.()); + + resolve(v1 as T); + } + }, + { + flush, + deep, + immediate: true, + }, + ); + }); + + const promises = [watcher]; + if (timeout !== null && timeout !== undefined) { + promises.push( + promiseTimeout(timeout, throwOnTimeout) + .then(() => toValue(r as MaybeRefOrGetter<T>)) + .finally(() => stop?.()), + ); + } + + return Promise.race(promises); + } + + function toBeTruthy(options?: UntilToMatchOptions): Promise<T> { + return toMatch(v => Boolean(v), options); + } + + function toBeNull(options?: UntilToMatchOptions): Promise<T> { + return toBe<null>(null, options); + } + + function toBeUndefined(options?: UntilToMatchOptions): Promise<T> { + return toBe<undefined>(undefined, options); + } + + function toBeNaN(options?: UntilToMatchOptions): Promise<T> { + return toMatch(Number.isNaN, options); + } + + function toContains(value: MaybeRefOrGetter<ElementOf<T>>, options?: UntilToMatchOptions): Promise<T> { + return toMatch((v) => { + const array = Array.from(v as Iterable<unknown>); + return array.includes(value) || array.includes(toValue(value)); + }, options); + } + + function changed(options?: UntilToMatchOptions): Promise<T> { + return changedTimes(1, options); + } + + function changedTimes(n = 1, options?: UntilToMatchOptions): Promise<T> { + let count = -1; + return toMatch(() => { + count += 1; + return count >= n; + }, options); + } + + if (Array.isArray(toValue(r as MaybeRefOrGetter<T>))) { + const instance: UntilArrayInstance<T> = { + toMatch: toMatch as UntilArrayInstance<T>['toMatch'], + toContains, + changed, + changedTimes, + get not() { + return createUntil(r, !isNot) as UntilArrayInstance<T>; + }, + }; + return instance; + } + + const instance: UntilValueInstance<T, boolean> = { + toMatch: toMatch as UntilValueInstance<T, boolean>['toMatch'], + toBe: toBe as UntilValueInstance<T, boolean>['toBe'], + toBeTruthy: toBeTruthy as UntilValueInstance<T, boolean>['toBeTruthy'], + toBeNull: toBeNull as UntilValueInstance<T, boolean>['toBeNull'], + toBeNaN, + toBeUndefined: toBeUndefined as UntilValueInstance<T, boolean>['toBeUndefined'], + changed, + changedTimes, + get not() { + return createUntil(r, !isNot) as UntilValueInstance<T, boolean>; + }, + }; + + return instance; +} + +/** + * @name until + * @category Reactivity + * @description Promised one-time watch for ref / getter changes. Resolve once the source matches a condition, optionally with a timeout. + * + * @param {WatchSource<T> | MaybeRefOrGetter<T>} r The reactive source to watch + * @returns {UntilValueInstance<T> | UntilArrayInstance<T>} A chainable instance exposing `toBe`, `toBeTruthy`, `toBeNull`, `toBeUndefined`, `toBeNaN`, `toMatch`, `toContains`, `changed`, `changedTimes`, and the `not` negation + * + * @example + * const count = ref(0); + * await until(count).toBe(7); + * + * @example + * const ready = ref(false); + * await until(ready).toBeTruthy(); + * + * @example + * // negation and timeout + * await until(count).not.toBe(0, { timeout: 1000, throwOnTimeout: true }); + * + * @example + * // resolve once the source changes n times + * await until(count).changedTimes(3); + * + * @since 0.0.15 + */ +export function until<T extends unknown[]>(r: WatchSource<T> | MaybeRefOrGetter<T>): UntilArrayInstance<T>; +export function until<T>(r: WatchSource<T> | MaybeRefOrGetter<T>): UntilValueInstance<T>; +export function until<T>(r: WatchSource<T> | MaybeRefOrGetter<T>): UntilValueInstance<T> | UntilArrayInstance<T> { + return createUntil(r); +} diff --git a/vue/toolkit/src/composables/reactivity/useArrayFilter/index.test.ts b/vue/toolkit/src/composables/reactivity/useArrayFilter/index.test.ts new file mode 100644 index 0000000..502c108 --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/useArrayFilter/index.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { ref } from 'vue'; +import { useArrayFilter } from '.'; + +describe(useArrayFilter, () => { + it('filters reactively', () => { + const list = ref([1, 2, 3, 4]); + const even = useArrayFilter(list, n => n % 2 === 0); + expect(even.value).toEqual([2, 4]); + + list.value = [1, 3, 5, 6]; + expect(even.value).toEqual([6]); + }); + + it('unwraps reactive items', () => { + const list = [ref(1), ref(2), ref(3)]; + const odd = useArrayFilter(list, n => n % 2 === 1); + expect(odd.value).toEqual([1, 3]); + }); +}); diff --git a/vue/toolkit/src/composables/reactivity/useArrayFilter/index.ts b/vue/toolkit/src/composables/reactivity/useArrayFilter/index.ts new file mode 100644 index 0000000..a987e65 --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/useArrayFilter/index.ts @@ -0,0 +1,24 @@ +import { computed, toValue } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter } from 'vue'; + +/** + * @name useArrayFilter + * @category Reactivity + * @description Reactive `Array.prototype.filter`. + * + * @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive) + * @param {(element: T, index: number, array: T[]) => boolean} fn Predicate + * @returns {ComputedRef<T[]>} The filtered array + * + * @example + * const list = ref([1, 2, 3, 4]); + * const even = useArrayFilter(list, n => n % 2 === 0); // [2, 4] + * + * @since 0.0.15 + */ +export function useArrayFilter<T>( + list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>, + fn: (element: T, index: number, array: T[]) => boolean, +): ComputedRef<T[]> { + return computed(() => toValue(list).map(i => toValue(i)).filter(fn)); +} diff --git a/vue/toolkit/src/composables/reactivity/useArrayFind/index.test.ts b/vue/toolkit/src/composables/reactivity/useArrayFind/index.test.ts new file mode 100644 index 0000000..ae55903 --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/useArrayFind/index.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { ref } from 'vue'; +import { useArrayFind } from '.'; + +describe(useArrayFind, () => { + it('finds reactively', () => { + const list = ref([1, 2, 3]); + const found = useArrayFind(list, n => n > 1); + expect(found.value).toBe(2); + + list.value = [10, 20]; + expect(found.value).toBe(10); + }); + + it('returns undefined when nothing matches', () => { + const found = useArrayFind(ref([1, 2]), n => n > 5); + expect(found.value).toBeUndefined(); + }); +}); diff --git a/vue/toolkit/src/composables/reactivity/useArrayFind/index.ts b/vue/toolkit/src/composables/reactivity/useArrayFind/index.ts new file mode 100644 index 0000000..563966e --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/useArrayFind/index.ts @@ -0,0 +1,24 @@ +import { computed, toValue } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter } from 'vue'; + +/** + * @name useArrayFind + * @category Reactivity + * @description Reactive `Array.prototype.find`. + * + * @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive) + * @param {(element: T, index: number, array: T[]) => boolean} fn Predicate + * @returns {ComputedRef<T | undefined>} The first matching element + * + * @example + * const list = ref([1, 2, 3]); + * const found = useArrayFind(list, n => n > 1); // 2 + * + * @since 0.0.15 + */ +export function useArrayFind<T>( + list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>, + fn: (element: T, index: number, array: T[]) => boolean, +): ComputedRef<T | undefined> { + return computed(() => toValue(list).map(i => toValue(i)).find(fn)); +} diff --git a/vue/toolkit/src/composables/reactivity/useArrayMap/index.test.ts b/vue/toolkit/src/composables/reactivity/useArrayMap/index.test.ts new file mode 100644 index 0000000..b025156 --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/useArrayMap/index.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { ref } from 'vue'; +import { useArrayMap } from '.'; + +describe(useArrayMap, () => { + it('maps reactively', () => { + const list = ref([1, 2, 3]); + const doubled = useArrayMap(list, n => n * 2); + expect(doubled.value).toEqual([2, 4, 6]); + + list.value = [4, 5]; + expect(doubled.value).toEqual([8, 10]); + }); + + it('unwraps reactive items', () => { + const list = [ref(1), ref(2)]; + const mapped = useArrayMap(list, n => n + 1); + expect(mapped.value).toEqual([2, 3]); + }); +}); diff --git a/vue/toolkit/src/composables/reactivity/useArrayMap/index.ts b/vue/toolkit/src/composables/reactivity/useArrayMap/index.ts new file mode 100644 index 0000000..0c7c936 --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/useArrayMap/index.ts @@ -0,0 +1,24 @@ +import { computed, toValue } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter } from 'vue'; + +/** + * @name useArrayMap + * @category Reactivity + * @description Reactive `Array.prototype.map`. + * + * @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive) + * @param {(element: T, index: number, array: T[]) => U} fn Mapper + * @returns {ComputedRef<U[]>} The mapped array + * + * @example + * const list = ref([1, 2, 3]); + * const doubled = useArrayMap(list, n => n * 2); // [2, 4, 6] + * + * @since 0.0.15 + */ +export function useArrayMap<T, U = T>( + list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>, + fn: (element: T, index: number, array: T[]) => U, +): ComputedRef<U[]> { + return computed(() => toValue(list).map(i => toValue(i)).map(fn)); +} diff --git a/vue/toolkit/src/composables/reactivity/useCached/index.test.ts b/vue/toolkit/src/composables/reactivity/useCached/index.test.ts index 2d94970..ec1ff31 100644 --- a/vue/toolkit/src/composables/reactivity/useCached/index.test.ts +++ b/vue/toolkit/src/composables/reactivity/useCached/index.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { ref, nextTick, reactive } from 'vue'; +import { nextTick, reactive, ref } from 'vue'; import { useCached } from '.'; const arrayEquals = (a: number[], b: number[]) => a.length === b.length && a.every((v, i) => v === b[i]); diff --git a/vue/toolkit/src/composables/reactivity/useCached/index.ts b/vue/toolkit/src/composables/reactivity/useCached/index.ts index afdcafe..d231d3e 100644 --- a/vue/toolkit/src/composables/reactivity/useCached/index.ts +++ b/vue/toolkit/src/composables/reactivity/useCached/index.ts @@ -1,4 +1,4 @@ -import { ref, watch, toValue } from 'vue'; +import { ref, toValue, watch } from 'vue'; import type { MaybeRefOrGetter, Ref, WatchOptions } from 'vue'; export type Comparator<Value> = (a: Value, b: Value) => boolean; diff --git a/vue/toolkit/src/composables/reactivity/useCloned/index.test.ts b/vue/toolkit/src/composables/reactivity/useCloned/index.test.ts new file mode 100644 index 0000000..b3a3483 --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/useCloned/index.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it, vi } from 'vitest'; +import { nextTick, reactive, ref } from 'vue'; +import { cloneFnDefault, useCloned } from '.'; + +describe(useCloned, () => { + it('clones the initial source value (deep, not referentially equal)', () => { + const source = ref({ nested: { count: 0 } }); + const { cloned } = useCloned(source); + + expect(cloned.value).toEqual({ nested: { count: 0 } }); + expect(cloned.value).not.toBe(source.value); + expect(cloned.value.nested).not.toBe(source.value.nested); + }); + + it('re-clones automatically when the source ref changes', async () => { + const source = ref({ count: 0 }); + const { cloned } = useCloned(source); + + source.value = { count: 5 }; + await nextTick(); + + expect(cloned.value).toEqual({ count: 5 }); + expect(cloned.value).not.toBe(source.value); + }); + + it('re-clones automatically when a deep source property changes', async () => { + const source = ref({ nested: { count: 0 } }); + const { cloned } = useCloned(source); + + source.value.nested.count = 9; + await nextTick(); + + expect(cloned.value.nested.count).toBe(9); + }); + + it('tracks modification of the cloned value via isModified', () => { + const source = ref({ count: 0 }); + const { cloned, isModified } = useCloned(source); + + expect(isModified.value).toBeFalsy(); + + cloned.value.count = 1; + + expect(isModified.value).toBeTruthy(); + }); + + it('does not set isModified when the change came from a source sync', async () => { + const source = ref({ count: 0 }); + const { isModified } = useCloned(source); + + source.value = { count: 1 }; + await nextTick(); + + expect(isModified.value).toBeFalsy(); + }); + + it('sync() re-clones from source and resets isModified', () => { + const source = ref({ count: 0 }); + const { cloned, isModified, sync } = useCloned(source); + + cloned.value.count = 42; + expect(isModified.value).toBeTruthy(); + + sync(); + + expect(cloned.value).toEqual({ count: 0 }); + expect(isModified.value).toBeFalsy(); + }); + + it('manual mode does not auto-sync on source change', async () => { + const source = ref({ count: 0 }); + const { cloned, sync } = useCloned(source, { manual: true }); + + expect(cloned.value).toEqual({ count: 0 }); + + source.value = { count: 100 }; + await nextTick(); + + // still the original clone, manual mode ignores source changes + expect(cloned.value).toEqual({ count: 0 }); + + sync(); + expect(cloned.value).toEqual({ count: 100 }); + }); + + it('supports a getter source', async () => { + const state = reactive({ count: 1 }); + const { cloned } = useCloned(() => ({ count: state.count })); + + expect(cloned.value).toEqual({ count: 1 }); + + state.count = 2; + await nextTick(); + + expect(cloned.value).toEqual({ count: 2 }); + }); + + it('supports a plain (non-reactive) source value', () => { + const { cloned, isModified } = useCloned({ count: 7 }); + + expect(cloned.value).toEqual({ count: 7 }); + expect(isModified.value).toBeFalsy(); + }); + + it('uses a custom clone function when provided', () => { + const clone = vi.fn((s: { count: number }) => ({ count: s.count + 1 })); + const source = ref({ count: 10 }); + const { cloned } = useCloned(source, { clone }); + + expect(clone).toHaveBeenCalled(); + expect(cloned.value).toEqual({ count: 11 }); + }); + + it('respects immediate: false (no clone until source changes)', async () => { + const source = ref({ count: 0 }); + const { cloned } = useCloned(source, { immediate: false }); + + // not yet synced + expect(cloned.value).toBeUndefined(); + + source.value = { count: 3 }; + await nextTick(); + + expect(cloned.value).toEqual({ count: 3 }); + }); +}); + +describe(cloneFnDefault, () => { + it('deep clones via structuredClone when available', () => { + const input = { a: 1, b: { c: [1, 2, 3] }, d: new Date(0) }; + const out = cloneFnDefault(input); + + expect(out).toEqual(input); + expect(out).not.toBe(input); + expect(out.b).not.toBe(input.b); + expect(out.d).toBeInstanceOf(Date); + }); + + it('falls back to JSON when structuredClone is unavailable (SSR / old runtime)', () => { + const original = globalThis.structuredClone; + // simulate environment without structuredClone + (globalThis as { structuredClone?: unknown }).structuredClone = undefined; + + try { + const input = { a: 1, b: { c: 2 } }; + const out = cloneFnDefault(input); + + expect(out).toEqual(input); + expect(out).not.toBe(input); + expect(out.b).not.toBe(input.b); + } + finally { + globalThis.structuredClone = original; + } + }); + + it('falls back to JSON when the value is not structured-cloneable', () => { + // functions are not structured-cloneable; JSON drops them + const input = { keep: 1, fn: () => {} }; + const out = cloneFnDefault(input) as { keep: number; fn?: unknown }; + + expect(out.keep).toBe(1); + expect(out.fn).toBeUndefined(); + }); +}); diff --git a/vue/toolkit/src/composables/reactivity/useCloned/index.ts b/vue/toolkit/src/composables/reactivity/useCloned/index.ts new file mode 100644 index 0000000..d86fdc8 --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/useCloned/index.ts @@ -0,0 +1,125 @@ +import { isRef, ref, toValue, watch } from 'vue'; +import type { MaybeRefOrGetter, Ref, WatchOptions } from 'vue'; +import { isFunction } from '@robonen/stdlib'; + +export type CloneFn<Source, Target = Source> = (source: Source) => Target; + +export interface UseClonedOptions<T = unknown> extends WatchOptions { + /** + * Custom clone function. + * + * By default uses `structuredClone` when available, falling back to + * `JSON.parse(JSON.stringify(value))`. + */ + clone?: CloneFn<T>; + + /** + * Manually sync the cloned ref instead of watching the source. + * + * @default false + */ + manual?: boolean; +} + +export interface UseClonedReturn<T> { + /** + * The cloned, mutable ref. + */ + cloned: Ref<T>; + + /** + * Whether the cloned data has been modified since the last sync. + */ + isModified: Ref<boolean>; + + /** + * Sync the cloned data with the source manually. + */ + sync: () => void; +} + +/** + * Default clone implementation. Prefers the structured clone algorithm and + * falls back to a JSON round-trip when `structuredClone` is unavailable + * (older runtimes / SSR) or the value is not structured-cloneable. + */ +export function cloneFnDefault<T>(source: T): T { + if (typeof structuredClone === 'function') { + try { + return structuredClone(source); + } + catch { + // value contains functions, symbols, etc. — fall back to JSON. + } + } + + return JSON.parse(JSON.stringify(source)) as T; +} + +/** + * @name useCloned + * @category Reactivity + * @description Reactive deep clone of a source with a mutable cloned ref, modification tracking, and manual mode. + * + * @param {MaybeRefOrGetter<T>} source The reactive source to clone (ref, getter, or plain value) + * @param {UseClonedOptions<T>} [options={}] Options: `clone`, `manual`, and watch options (`deep`, `immediate`, `flush`) + * @returns {UseClonedReturn<T>} The cloned ref, an `isModified` flag, and a `sync` function + * + * @example + * const original = ref({ count: 0 }); + * const { cloned, isModified, sync } = useCloned(original); + * cloned.value.count = 1; // isModified.value === true + * sync(); // re-clone from source, isModified.value === false + * + * @example + * const { cloned, sync } = useCloned(source, { manual: true }); + * // cloned only updates when sync() is called + * + * @since 0.0.15 + */ +export function useCloned<T>( + source: MaybeRefOrGetter<T>, + options: UseClonedOptions<T> = {}, +): UseClonedReturn<T> { + const cloned = ref<T>() as Ref<T>; + const isModified = ref(false); + let lastSync = false; + + const { + manual, + clone = cloneFnDefault, + deep = true, + immediate = true, + } = options; + + function sync(): void { + lastSync = true; + isModified.value = false; + cloned.value = clone(toValue(source)); + } + + watch(cloned, () => { + if (lastSync) { + lastSync = false; + return; + } + + isModified.value = true; + }, { + deep: true, + flush: 'sync', + }); + + if (!manual && (isRef(source) || isFunction(source))) { + watch(source, sync, { + ...options, + deep, + immediate, + }); + } + else { + sync(); + } + + return { cloned, isModified, sync }; +} diff --git a/vue/toolkit/src/composables/reactivity/useCycleList/index.test.ts b/vue/toolkit/src/composables/reactivity/useCycleList/index.test.ts new file mode 100644 index 0000000..78d3018 --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/useCycleList/index.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest'; +import { nextTick, ref } from 'vue'; +import { useCycleList } from '.'; + +describe(useCycleList, () => { + it('starts at the first item', () => { + const { state, index } = useCycleList(['a', 'b', 'c']); + expect(state.value).toBe('a'); + expect(index.value).toBe(0); + }); + + it('cycles forward and wraps around', () => { + const { state, next } = useCycleList(['a', 'b', 'c']); + expect(next()).toBe('b'); + expect(next()).toBe('c'); + expect(next()).toBe('a'); + expect(state.value).toBe('a'); + }); + + it('cycles backward and wraps around', () => { + const { prev } = useCycleList(['a', 'b', 'c']); + expect(prev()).toBe('c'); + expect(prev()).toBe('b'); + }); + + it('honors initialValue', () => { + const { state, index } = useCycleList(['a', 'b', 'c'], { initialValue: 'b' }); + expect(state.value).toBe('b'); + expect(index.value).toBe(1); + }); + + it('honors a ref initialValue', () => { + const initialValue = ref('c'); + const { state, index } = useCycleList(['a', 'b', 'c'], { initialValue }); + expect(state.value).toBe('c'); + expect(index.value).toBe(2); + }); + + it('go jumps to an index', () => { + const { go, state } = useCycleList(['a', 'b', 'c']); + expect(go(2)).toBe('c'); + expect(state.value).toBe('c'); + }); + + it('go wraps negative and out-of-range indices', () => { + const { go } = useCycleList(['a', 'b', 'c']); + expect(go(-1)).toBe('c'); + expect(go(3)).toBe('a'); + expect(go(4)).toBe('b'); + }); + + it('next/prev accept a step count', () => { + const { next, prev } = useCycleList(['a', 'b', 'c', 'd']); + expect(next(2)).toBe('c'); + expect(prev(3)).toBe('d'); + }); + + it('shift moves by a signed delta', () => { + const { shift, state } = useCycleList(['a', 'b', 'c', 'd']); + expect(shift(2)).toBe('c'); + expect(shift(-1)).toBe('b'); + expect(shift()).toBe('c'); + expect(state.value).toBe('c'); + }); + + it('exposes a writable index', () => { + const { index, state } = useCycleList(['a', 'b', 'c']); + index.value = 2; + expect(state.value).toBe('c'); + expect(index.value).toBe(2); + + // Out-of-range assignments wrap into bounds. + index.value = 4; + expect(state.value).toBe('b'); + expect(index.value).toBe(1); + }); + + it('supports a getter-based list', () => { + const source = ref(['a', 'b', 'c']); + const { state, next, index } = useCycleList(() => source.value); + expect(state.value).toBe('a'); + expect(next()).toBe('b'); + + source.value = ['x', 'b', 'y']; + expect(state.value).toBe('b'); + expect(index.value).toBe(1); + }); + + it('uses a custom getIndexOf resolver', () => { + const list = [{ id: 1 }, { id: 2 }, { id: 3 }]; + const { state, index, next } = useCycleList(list, { + initialValue: { id: 2 }, + getIndexOf: (value, l) => l.findIndex(item => item.id === value.id), + }); + expect(index.value).toBe(1); + expect(next()).toEqual({ id: 3 }); + }); + + it('falls back to fallbackIndex when the current item is missing', () => { + const { index } = useCycleList(['a', 'b', 'c'], { + initialValue: 'z', + fallbackIndex: 2, + }); + expect(index.value).toBe(2); + }); + + it('preserves the current item across list changes when it still exists', () => { + const list = ref(['a', 'b', 'c', 'd']); + const { go, state, index } = useCycleList(list); + go(1); + expect(state.value).toBe('b'); + + list.value = ['x', 'b', 'y']; + expect(state.value).toBe('b'); + expect(index.value).toBe(1); + }); + + it('falls back to fallbackIndex when the current item disappears', async () => { + const list = ref(['a', 'b', 'c']); + const { go, state } = useCycleList(list, { fallbackIndex: 0 }); + go(2); + expect(state.value).toBe('c'); + + list.value = ['x', 'y']; + await nextTick(); + expect(state.value).toBe('x'); + }); + + it('does not corrupt state when the list is empty', () => { + const list = ref<string[]>([]); + const { state, next, prev, go } = useCycleList(list, { initialValue: 'a' }); + expect(state.value).toBe('a'); + // Operations on an empty list are no-ops rather than producing undefined. + expect(next()).toBe('a'); + expect(prev()).toBe('a'); + expect(go(5)).toBe('a'); + expect(state.value).toBe('a'); + }); + + it('does not throw when a non-empty list becomes empty', async () => { + const list = ref(['a', 'b', 'c']); + const { state, go } = useCycleList(list); + go(1); + expect(state.value).toBe('b'); + + list.value = []; + await nextTick(); + // State is retained (no NaN/undefined) when there is nothing to cycle to. + expect(state.value).toBe('b'); + }); +}); diff --git a/vue/toolkit/src/composables/reactivity/useCycleList/index.ts b/vue/toolkit/src/composables/reactivity/useCycleList/index.ts new file mode 100644 index 0000000..be6dfd2 --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/useCycleList/index.ts @@ -0,0 +1,152 @@ +import { computed, shallowRef, toRef, toValue, watch } from 'vue'; +import type { MaybeRef, MaybeRefOrGetter, Ref, ShallowRef, WritableComputedRef } from 'vue'; + +export interface UseCycleListOptions<T> { + /** + * The initial value of the state. Defaults to the first item in the list. + * A ref can be provided to reuse it. + */ + initialValue?: MaybeRef<T>; + + /** + * Index used when the current value is not found in the list. + * + * @default 0 + */ + fallbackIndex?: number; + + /** + * Custom function to resolve the index of a value in the list. + */ + getIndexOf?: (value: T, list: T[]) => number; +} + +export interface UseCycleListReturn<T> { + /** + * The currently selected item. + */ + state: ShallowRef<T>; + + /** + * The index of the currently selected item. Writable — assigning jumps to that index. + */ + index: WritableComputedRef<number>; + + /** + * Move forward by `n` items (wraps around). Defaults to 1. + */ + next: (n?: number) => T; + + /** + * Move backward by `n` items (wraps around). Defaults to 1. + */ + prev: (n?: number) => T; + + /** + * Move by a signed `delta` relative to the current item (wraps around). Defaults to 1. + */ + shift: (delta?: number) => T; + + /** + * Jump to a specific index (wraps around out-of-range/negative indices). + */ + go: (i: number) => T; +} + +/** + * @name useCycleList + * @category Reactivity + * @description Cycle through a list of items, with `next`/`prev`/`shift`/`go` controls. + * Supports a reactive list — the index is kept valid when the list changes. + * + * @param {MaybeRefOrGetter<T[]>} list The list to cycle through (can be reactive) + * @param {UseCycleListOptions<T>} [options={}] Options + * @returns {UseCycleListReturn<T>} State and controls + * + * @example + * const { state, next, prev } = useCycleList(['a', 'b', 'c']); + * next(); // state.value === 'b' + * + * @example + * const { index } = useCycleList(['a', 'b', 'c']); + * index.value = 2; // jump directly to 'c' + * + * @since 0.0.15 + */ +export function useCycleList<T>( + list: MaybeRefOrGetter<T[]>, + options: UseCycleListOptions<T> = {}, +): UseCycleListReturn<T> { + const { fallbackIndex = 0, getIndexOf } = options; + + // Normalize the source once: a stable ref we can watch and read cheaply, + // regardless of whether the caller passed an array, a ref, or a getter. + const listRef = toRef(list) as Ref<T[]>; + + const state = shallowRef( + options.initialValue !== undefined ? toValue(options.initialValue) : listRef.value[0], + ) as ShallowRef<T>; + + const index = computed<number>({ + get() { + const targetList = listRef.value; + + let position = getIndexOf + ? getIndexOf(state.value, targetList) + : targetList.indexOf(state.value); + + if (position < 0) + position = fallbackIndex; + + return position; + }, + set(value) { + set(value); + }, + }); + + function set(i: number): T { + const targetList = listRef.value; + const length = targetList.length; + + // Nothing to select — keep the current state untouched (avoids NaN indexing). + if (length === 0) + return state.value; + + // Wrap negative and out-of-range indices into bounds. + const position = ((i % length) + length) % length; + const value = targetList[position]!; + + state.value = value; + return value; + } + + function go(i: number): T { + return set(i); + } + + function shift(delta = 1): T { + return set(index.value + delta); + } + + function next(n = 1): T { + return shift(n); + } + + function prev(n = 1): T { + return shift(-n); + } + + // Keep the state in sync when the list shrinks/changes: re-resolving the + // current index falls back automatically if the active item disappeared. + watch(listRef, () => set(index.value)); + + return { + state, + index, + next, + prev, + shift, + go, + }; +} diff --git a/vue/toolkit/src/composables/reactivity/useLastChanged/index.test.ts b/vue/toolkit/src/composables/reactivity/useLastChanged/index.test.ts index f532517..e71ebf7 100644 --- a/vue/toolkit/src/composables/reactivity/useLastChanged/index.test.ts +++ b/vue/toolkit/src/composables/reactivity/useLastChanged/index.test.ts @@ -1,5 +1,5 @@ -import { ref, nextTick } from 'vue'; -import { describe, it, expect } from 'vitest'; +import { nextTick, ref } from 'vue'; +import { describe, expect, it } from 'vitest'; import { useLastChanged } from '.'; import { timestamp } from '@robonen/stdlib'; diff --git a/vue/toolkit/src/composables/reactivity/useLastChanged/index.ts b/vue/toolkit/src/composables/reactivity/useLastChanged/index.ts index dbe7ef6..7600151 100644 --- a/vue/toolkit/src/composables/reactivity/useLastChanged/index.ts +++ b/vue/toolkit/src/composables/reactivity/useLastChanged/index.ts @@ -1,6 +1,6 @@ import { timestamp } from '@robonen/stdlib'; import { ref, watch } from 'vue'; -import type { WatchSource, WatchOptions, Ref } from 'vue'; +import type { Ref, WatchOptions, WatchSource } from 'vue'; export interface UseLastChangedOptions< Immediate extends boolean, diff --git a/vue/toolkit/src/composables/reactivity/usePrevious/index.test.ts b/vue/toolkit/src/composables/reactivity/usePrevious/index.test.ts new file mode 100644 index 0000000..401168c --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/usePrevious/index.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from 'vitest'; +import { effectScope, isReadonly, nextTick, reactive, ref } from 'vue'; +import { usePrevious } from '.'; + +describe(usePrevious, () => { + it('is undefined before any change', () => { + const count = ref(0); + const prev = usePrevious(count); + expect(prev.value).toBeUndefined(); + }); + + it('uses the provided initial value', () => { + const count = ref(0); + const prev = usePrevious(count, -1); + expect(prev.value).toBe(-1); + }); + + it('tracks the previous value on change', () => { + const count = ref(0); + const prev = usePrevious(count); + + count.value = 1; + expect(prev.value).toBe(0); + + count.value = 5; + expect(prev.value).toBe(1); + }); + + it('works with a getter source', () => { + const obj = ref({ n: 1 }); + const prev = usePrevious(() => obj.value.n); + obj.value = { n: 2 }; + expect(prev.value).toBe(1); + }); + + it('returns a readonly ref', () => { + const count = ref(0); + const prev = usePrevious(count); + expect(isReadonly(prev)).toBeTruthy(); + }); + + it('does not throw when writing to the readonly ref (warns instead)', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const count = ref(0); + const prev = usePrevious(count); + // @ts-expect-error: readonly ref must not be writable at the type level + prev.value = 99; + expect(prev.value).toBeUndefined(); + warn.mockRestore(); + }); + + it('updates synchronously by default', () => { + const count = ref(0); + const prev = usePrevious(count); + count.value = 1; + // no flush/tick needed with the default sync flush + expect(prev.value).toBe(0); + }); + + it('respects a custom flush timing', async () => { + const count = ref(0); + const prev = usePrevious(count, undefined, { flush: 'post' }); + + count.value = 1; + // post flush is deferred until after the next tick + expect(prev.value).toBeUndefined(); + + await nextTick(); + expect(prev.value).toBe(0); + }); + + it('tracks deep mutations with the deep option', () => { + const state = reactive({ n: 1 }); + const prev = usePrevious(() => ({ ...state }), undefined, { deep: true }); + + state.n = 2; + expect(prev.value).toEqual({ n: 1 }); + + state.n = 3; + expect(prev.value).toEqual({ n: 2 }); + }); + + it('accepts a raw (non-ref) source', () => { + const prev = usePrevious(5); + expect(prev.value).toBeUndefined(); + }); + + it('stops tracking when the owning scope is disposed', () => { + const count = ref(0); + const scope = effectScope(); + + const prev = scope.run(() => usePrevious(count))!; + + count.value = 1; + expect(prev.value).toBe(0); + + scope.stop(); + + count.value = 2; + // watcher is torn down with the scope, so the previous value is frozen + expect(prev.value).toBe(0); + }); +}); diff --git a/vue/toolkit/src/composables/reactivity/usePrevious/index.ts b/vue/toolkit/src/composables/reactivity/usePrevious/index.ts new file mode 100644 index 0000000..b82ca89 --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/usePrevious/index.ts @@ -0,0 +1,49 @@ +import { shallowReadonly, shallowRef, toRef, watch } from 'vue'; +import type { MaybeRefOrGetter, ShallowRef, WatchOptions } from 'vue'; + +export type UsePreviousOptions = Pick<WatchOptions, 'deep' | 'flush'>; + +/** + * @name usePrevious + * @category Reactivity + * @description Track the previous value of a ref, getter, or reactive source. + * + * @param {MaybeRefOrGetter<T>} value The source value to track + * @param {T} [initialValue] The initial previous value, or an options object + * @param {UsePreviousOptions} [options={}] Watch options (`deep`, `flush`) + * @returns {Readonly<ShallowRef<T | undefined>>} The previous value of the source + * + * @example + * const count = ref(0); + * const prev = usePrevious(count); + * count.value = 1; // prev.value === 0 + * + * @example + * const count = ref(0); + * const prev = usePrevious(count, -1); // prev.value === -1 until count changes + * + * @example + * const state = reactive({ n: 1 }); + * const prev = usePrevious(() => ({ ...state }), undefined, { deep: true }); + * + * @since 0.0.15 + */ +export function usePrevious<T>(value: MaybeRefOrGetter<T>, initialValue: T, options?: UsePreviousOptions): Readonly<ShallowRef<T>>; +export function usePrevious<T>(value: MaybeRefOrGetter<T>, initialValue?: undefined, options?: UsePreviousOptions): Readonly<ShallowRef<T | undefined>>; +export function usePrevious<T>( + value: MaybeRefOrGetter<T>, + initialValue?: T, + options: UsePreviousOptions = {}, +): Readonly<ShallowRef<T | undefined>> { + const previous = shallowRef<T | undefined>(initialValue); + + watch( + toRef(value), + (_, oldValue) => { + previous.value = oldValue; + }, + { flush: options.flush ?? 'sync', deep: options.deep }, + ); + + return shallowReadonly(previous); +} diff --git a/vue/toolkit/src/composables/reactivity/useSyncRefs/index.ts b/vue/toolkit/src/composables/reactivity/useSyncRefs/index.ts index 8f35c8d..8ef9847 100644 --- a/vue/toolkit/src/composables/reactivity/useSyncRefs/index.ts +++ b/vue/toolkit/src/composables/reactivity/useSyncRefs/index.ts @@ -27,7 +27,7 @@ import { isArray } from '@robonen/stdlib'; */ export function useSyncRefs<T = unknown>( source: WatchSource<T>, - targets: Ref<T> | Ref<T>[], + targets: Ref<T> | Array<Ref<T>>, watchOptions: WatchOptions = {}, ) { const { diff --git a/vue/toolkit/src/composables/reactivity/useToNumber/index.test.ts b/vue/toolkit/src/composables/reactivity/useToNumber/index.test.ts new file mode 100644 index 0000000..9cc02c0 --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/useToNumber/index.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import { ref } from 'vue'; +import { useToNumber } from '.'; + +describe(useToNumber, () => { + it('parses a numeric string with parseFloat by default', () => { + const str = ref('42.5'); + const num = useToNumber(str); + expect(num.value).toBe(42.5); + }); + + it('reacts to source changes', () => { + const str = ref('1'); + const num = useToNumber(str); + expect(num.value).toBe(1); + str.value = '2.5'; + expect(num.value).toBe(2.5); + }); + + it('passes through numbers unchanged', () => { + expect(useToNumber(10).value).toBe(10); + }); + + it('does not truncate a number source when method is parseInt', () => { + expect(useToNumber(3.9, { method: 'parseInt' }).value).toBe(3.9); + }); + + it('uses parseInt with radix', () => { + expect(useToNumber('ff', { method: 'parseInt', radix: 16 }).value).toBe(255); + }); + + it('resolves NaN to 0 when nanToZero is set', () => { + expect(useToNumber('abc', { nanToZero: true }).value).toBe(0); + expect(useToNumber('abc').value).toBeNaN(); + }); + + it('supports a custom converter function', () => { + const num = useToNumber('10.4', { method: v => Math.round(+v) }); + expect(num.value).toBe(10); + }); + + it('applies the custom converter to number sources too', () => { + const num = useToNumber(3.7, { method: v => Math.floor(+v) }); + expect(num.value).toBe(3); + }); + + it('reacts to source changes with a custom converter', () => { + const src = ref<number | string>('5.6'); + const num = useToNumber(src, { method: v => Math.round(+v) }); + expect(num.value).toBe(6); + src.value = 2.2; + expect(num.value).toBe(2); + }); + + it('clamps to min', () => { + expect(useToNumber('-5', { min: 0 }).value).toBe(0); + expect(useToNumber('5', { min: 0 }).value).toBe(5); + }); + + it('clamps to max', () => { + expect(useToNumber('150', { max: 100 }).value).toBe(100); + expect(useToNumber('50', { max: 100 }).value).toBe(50); + }); + + it('clamps to both min and max', () => { + expect(useToNumber('-10', { min: 0, max: 100 }).value).toBe(0); + expect(useToNumber('200', { min: 0, max: 100 }).value).toBe(100); + expect(useToNumber('42', { min: 0, max: 100 }).value).toBe(42); + }); + + it('reacts to clamped source changes', () => { + const src = ref('5'); + const num = useToNumber(src, { min: 0, max: 10 }); + expect(num.value).toBe(5); + src.value = '20'; + expect(num.value).toBe(10); + src.value = '-3'; + expect(num.value).toBe(0); + }); + + it('applies nanToZero before clamping', () => { + expect(useToNumber('abc', { nanToZero: true, min: 1 }).value).toBe(1); + }); + + it('supports getter sources', () => { + const base = ref(2); + const num = useToNumber(() => `${base.value}5`); + expect(num.value).toBe(25); + base.value = 9; + expect(num.value).toBe(95); + }); +}); diff --git a/vue/toolkit/src/composables/reactivity/useToNumber/index.ts b/vue/toolkit/src/composables/reactivity/useToNumber/index.ts new file mode 100644 index 0000000..81cd695 --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/useToNumber/index.ts @@ -0,0 +1,97 @@ +import { computed, toValue } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter } from 'vue'; +import { clamp, isFunction, isNumber, isString } from '@robonen/stdlib'; + +export type UseToNumberMethod = 'parseFloat' | 'parseInt' | ((value: number | string) => number); + +export interface UseToNumberOptions { + /** + * Parsing method for string input, or a custom converter function + * + * @default 'parseFloat' + */ + method?: UseToNumberMethod; + + /** + * Radix for `parseInt` + */ + radix?: number; + + /** + * Resolve `NaN` to `0` + * + * @default false + */ + nanToZero?: boolean; + + /** + * Clamp the result to a minimum value (applied after parsing) + */ + min?: number; + + /** + * Clamp the result to a maximum value (applied after parsing) + */ + max?: number; +} + +/** + * @name useToNumber + * @category Reactivity + * @description Reactively convert a string or number ref to a number. + * + * @param {MaybeRefOrGetter<number | string>} value The source value (can be reactive) + * @param {UseToNumberOptions} [options={}] Options + * @returns {ComputedRef<number>} The numeric value + * + * @example + * const str = ref('42.5'); + * const num = useToNumber(str); // 42.5 + * + * @example + * // custom converter and clamping + * const n = useToNumber(input, { method: v => Math.round(+v), min: 0, max: 100 }); + * + * @since 0.0.15 + */ +export function useToNumber( + value: MaybeRefOrGetter<number | string>, + options: UseToNumberOptions = {}, +): ComputedRef<number> { + const { + method = 'parseFloat', + radix, + nanToZero = false, + min, + max, + } = options; + + // Hoist the parser resolution out of the computed so the property lookup / + // function-type check happens once instead of on every recompute. + const parse: (source: number | string) => number = isFunction(method) + ? method + : source => (isNumber(source) ? source : Number[method](source, radix)); + + const hasMin = isNumber(min); + const hasMax = isNumber(max); + + return computed<number>(() => { + const source = toValue(value); + + let resolved = isString(source) || isFunction(method) + ? parse(source) + : source; + + if (nanToZero && Number.isNaN(resolved)) + resolved = 0; + + if (hasMin && hasMax) + resolved = clamp(resolved, min, max); + else if (hasMin && resolved < min) + resolved = min; + else if (hasMax && resolved > max) + resolved = max; + + return resolved; + }); +} diff --git a/vue/toolkit/src/composables/reactivity/useToString/index.test.ts b/vue/toolkit/src/composables/reactivity/useToString/index.test.ts new file mode 100644 index 0000000..9c1e5be --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/useToString/index.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import { ref } from 'vue'; +import { useToString } from '.'; + +describe(useToString, () => { + it('stringifies a number ref', () => { + const num = ref(42); + const str = useToString(num); + expect(str.value).toBe('42'); + }); + + it('reacts to source changes', () => { + const num = ref(1); + const str = useToString(num); + expect(str.value).toBe('1'); + num.value = 2; + expect(str.value).toBe('2'); + }); + + it('stringifies a plain (non-reactive) value', () => { + expect(useToString(10).value).toBe('10'); + expect(useToString('hello').value).toBe('hello'); + }); + + it('stringifies booleans', () => { + expect(useToString(true).value).toBe('true'); + expect(useToString(false).value).toBe('false'); + }); + + it('stringifies null and undefined like String()', () => { + expect(useToString(null).value).toBe('null'); + expect(useToString(undefined).value).toBe('undefined'); + }); + + it('passes through string sources unchanged', () => { + const src = ref('already a string'); + expect(useToString(src).value).toBe('already a string'); + }); + + it('stringifies objects via their toString', () => { + expect(useToString({}).value).toBe('[object Object]'); + expect(useToString([1, 2, 3]).value).toBe('1,2,3'); + }); + + it('honors a custom toString on objects', () => { + const obj = { toString: () => 'custom' }; + expect(useToString(obj).value).toBe('custom'); + }); + + it('supports getter sources', () => { + const base = ref(2); + const str = useToString(() => `item-${base.value}`); + expect(str.value).toBe('item-2'); + base.value = 9; + expect(str.value).toBe('item-9'); + }); + + it('reacts to a getter returning different types', () => { + const src = ref<unknown>(0); + const str = useToString(() => src.value); + expect(str.value).toBe('0'); + src.value = true; + expect(str.value).toBe('true'); + src.value = null; + expect(str.value).toBe('null'); + }); + + it('returns a readonly computed ref', () => { + const str = useToString(ref(1)); + expect(typeof str.value).toBe('string'); + // ComputedRef exposes a value getter; result is always a string + expect(str.value).toBe('1'); + }); +}); diff --git a/vue/toolkit/src/composables/reactivity/useToString/index.ts b/vue/toolkit/src/composables/reactivity/useToString/index.ts new file mode 100644 index 0000000..a8787e5 --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/useToString/index.ts @@ -0,0 +1,26 @@ +import { computed, toValue } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter } from 'vue'; + +/** + * @name useToString + * @category Reactivity + * @description Reactively stringify a value, equivalent to `computed(() => String(toValue(value)))`. + * + * @param {MaybeRefOrGetter<unknown>} value The source value (can be a ref, getter, or plain value) + * @returns {ComputedRef<string>} The string representation of the value + * + * @example + * const count = ref(42); + * const str = useToString(count); // '42' + * + * @example + * // works with getters + * const label = useToString(() => `item-${id.value}`); + * + * @since 0.0.15 + */ +export function useToString( + value: MaybeRefOrGetter<unknown>, +): ComputedRef<string> { + return computed<string>(() => `${toValue(value)}`); +} diff --git a/vue/toolkit/src/composables/reactivity/watchDebounced/index.test.ts b/vue/toolkit/src/composables/reactivity/watchDebounced/index.test.ts new file mode 100644 index 0000000..8cad6a4 --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/watchDebounced/index.test.ts @@ -0,0 +1,270 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, nextTick, reactive, ref } from 'vue'; +import { debouncedWatch, watchDebounced } from '.'; + +describe(watchDebounced, () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('does not fire before the source changes', () => { + const count = ref(0); + const cb = vi.fn(); + + watchDebounced(count, cb, { debounce: 100, flush: 'sync' }); + + vi.advanceTimersByTime(200); + expect(cb).not.toHaveBeenCalled(); + }); + + it('defers the callback by the debounce delay', () => { + const count = ref(0); + const cb = vi.fn(); + + watchDebounced(count, cb, { debounce: 100, flush: 'sync' }); + + count.value = 1; + expect(cb).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(50); + expect(cb).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(50); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function)); + }); + + it('coalesces rapid changes into a single trailing call', () => { + const count = ref(0); + const cb = vi.fn(); + + watchDebounced(count, cb, { debounce: 100, flush: 'sync' }); + + count.value = 1; + vi.advanceTimersByTime(80); + count.value = 2; + vi.advanceTimersByTime(80); + count.value = 3; + + expect(cb).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(100); + expect(cb).toHaveBeenCalledTimes(1); + // The filtered callback receives the args of the latest watch trigger: + // new value 3, and old value 2 (the source value just before the last change). + expect(cb).toHaveBeenLastCalledWith(3, 2, expect.any(Function)); + }); + + it('fires synchronously with no debounce (debounce = 0)', () => { + const count = ref(0); + const cb = vi.fn(); + + watchDebounced(count, cb, { flush: 'sync' }); + + count.value = 1; + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function)); + }); + + it('enforces maxWait under sustained changes', () => { + const count = ref(0); + const cb = vi.fn(); + + watchDebounced(count, cb, { debounce: 100, maxWait: 250, flush: 'sync' }); + + // Keep changing before the debounce timer can ever settle. + count.value = 1; + vi.advanceTimersByTime(80); + count.value = 2; + vi.advanceTimersByTime(80); + count.value = 3; + vi.advanceTimersByTime(80); + // 240ms elapsed, debounce has reset each time, but maxWait is 250ms. + expect(cb).not.toHaveBeenCalled(); + + count.value = 4; + vi.advanceTimersByTime(20); + // maxWait (250ms) elapsed -> forced invocation with the latest trigger args. + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenLastCalledWith(4, 3, expect.any(Function)); + }); + + it('does not double-fire when maxWait and debounce settle together', () => { + const count = ref(0); + const cb = vi.fn(); + + watchDebounced(count, cb, { debounce: 100, maxWait: 200, flush: 'sync' }); + + count.value = 1; + // debounce settles at 100ms; maxWait would be at 200ms but is cleared. + vi.advanceTimersByTime(100); + expect(cb).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(200); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('supports a reactive debounce delay', () => { + const count = ref(0); + const delay = ref(100); + const cb = vi.fn(); + + watchDebounced(count, cb, { debounce: delay, flush: 'sync' }); + + count.value = 1; + vi.advanceTimersByTime(100); + expect(cb).toHaveBeenCalledTimes(1); + + delay.value = 300; + count.value = 2; + vi.advanceTimersByTime(100); + expect(cb).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(200); + expect(cb).toHaveBeenCalledTimes(2); + }); + + it('works with a getter source', () => { + const count = ref(0); + const cb = vi.fn(); + + watchDebounced(() => count.value * 2, cb, { debounce: 100, flush: 'sync' }); + + count.value = 5; + vi.advanceTimersByTime(100); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenLastCalledWith(10, 0, expect.any(Function)); + }); + + it('works with an array of sources', () => { + const a = ref(0); + const b = ref('x'); + const cb = vi.fn(); + + watchDebounced([a, b], cb, { debounce: 100, flush: 'sync' }); + + a.value = 1; + vi.advanceTimersByTime(100); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenLastCalledWith([1, 'x'], [0, 'x'], expect.any(Function)); + }); + + it('works with a reactive object source and deep option', () => { + const state = reactive({ nested: { value: 0 } }); + const cb = vi.fn(); + + watchDebounced(state, cb, { debounce: 100, deep: true, flush: 'sync' }); + + state.nested.value = 1; + vi.advanceTimersByTime(100); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('fires immediately with the immediate option', () => { + const count = ref(0); + const cb = vi.fn(); + + watchDebounced(count, cb, { debounce: 100, immediate: true, flush: 'sync' }); + + // immediate runs through the filter synchronously only when debounce=0; + // with a positive debounce the immediate run is also debounced. + vi.advanceTimersByTime(100); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenLastCalledWith(0, undefined, expect.any(Function)); + }); + + it('respects a custom flush timing', async () => { + const count = ref(0); + const cb = vi.fn(); + + watchDebounced(count, cb, { debounce: 100, flush: 'post' }); + + count.value = 1; + await nextTick(); + expect(cb).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(100); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('returns a handle that stops watching', () => { + const count = ref(0); + const cb = vi.fn(); + + const stop = watchDebounced(count, cb, { debounce: 100, flush: 'sync' }); + + stop(); + + count.value = 1; + vi.advanceTimersByTime(200); + expect(cb).not.toHaveBeenCalled(); + }); + + it('stops watching when the owning scope is disposed', () => { + const count = ref(0); + const cb = vi.fn(); + const scope = effectScope(); + + scope.run(() => watchDebounced(count, cb, { debounce: 100, flush: 'sync' })); + + scope.stop(); + + count.value = 1; + vi.advanceTimersByTime(200); + expect(cb).not.toHaveBeenCalled(); + }); + + it('honours a caller-supplied eventFilter over debounce/maxWait', () => { + const count = ref(0); + const cb = vi.fn(); + const passthrough = vi.fn((invoke: () => void) => invoke()); + + watchDebounced(count, cb, { + debounce: 1000, + eventFilter: passthrough, + flush: 'sync', + }); + + count.value = 1; + // The custom filter invokes immediately, bypassing the debounce timer. + expect(passthrough).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function)); + }); + + it('passes onCleanup to the callback', () => { + const count = ref(0); + const cleanup = vi.fn(); + + watchDebounced(count, (_value, _old, onCleanup) => { + onCleanup(cleanup); + }, { debounce: 100, flush: 'sync' }); + + count.value = 1; + vi.advanceTimersByTime(100); + count.value = 2; + vi.advanceTimersByTime(100); + + // cleanup registered on the first settled run fires before the second. + expect(cleanup).toHaveBeenCalledTimes(1); + }); + + it('exposes debouncedWatch as an alias', () => { + expect(debouncedWatch).toBe(watchDebounced); + }); + + it('runs in a non-DOM scope without touching globals (SSR-safe)', () => { + const count = ref(0); + const cb = vi.fn(); + const scope = effectScope(); + + expect(() => { + scope.run(() => watchDebounced(count, cb, { debounce: 100, flush: 'sync' })); + }).not.toThrow(); + + scope.stop(); + }); +}); diff --git a/vue/toolkit/src/composables/reactivity/watchDebounced/index.ts b/vue/toolkit/src/composables/reactivity/watchDebounced/index.ts new file mode 100644 index 0000000..94db08e --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/watchDebounced/index.ts @@ -0,0 +1,111 @@ +import { watch } from 'vue'; +import type { + MaybeRefOrGetter, + WatchCallback, + WatchHandle, + WatchOptions, + WatchSource, +} from 'vue'; +import { createFilterWrapper, debounceFilter } from '@/utils/filters'; +import type { ConfigurableEventFilter, EventFilter } from '@/utils/filters'; + +type MultiWatchSources = Array<WatchSource<unknown> | object>; + +type MapSources<T> = { + [K in keyof T]: T[K] extends WatchSource<infer V> ? V : T[K] extends object ? T[K] : never; +}; + +type MapOldSources<T, Immediate> = { + [K in keyof T]: T[K] extends WatchSource<infer V> + ? Immediate extends true ? V | undefined : V + : T[K] extends object + ? Immediate extends true ? T[K] | undefined : T[K] + : never; +}; + +export interface WatchDebouncedOptions<Immediate> extends WatchOptions<Immediate>, ConfigurableEventFilter { + /** + * Delay in milliseconds before the watch callback fires after the last + * source change. Resets on every change. Can be reactive. + * + * @default 0 + */ + debounce?: MaybeRefOrGetter<number>; + + /** + * The maximum time the callback is allowed to be delayed before it is + * forcibly invoked, even while the source keeps changing. Guarantees the + * callback runs at least once per `maxWait` window under sustained input. + * When omitted there is no upper bound. Can be reactive. + * + * @default undefined + */ + maxWait?: MaybeRefOrGetter<number>; +} + +/** + * @name watchDebounced + * @category Reactivity + * @description Debounced `watch`. The callback is postponed until `debounce` + * milliseconds have elapsed since the last source change; an optional `maxWait` + * caps how long it can be delayed under sustained changes. Implemented via an + * event filter so the public surface matches `watch` exactly. + * + * @param {WatchSource<T> | T} source The reactive source (ref, getter, reactive object, or array of sources) to watch + * @param {WatchCallback} cb Invoked with the new value, old value, and `onCleanup` once the debounce settles + * @param {WatchDebouncedOptions} [options] Watch options plus `debounce` (ms) and `maxWait` (ms ceiling) + * @returns {WatchHandle} A handle to stop watching (also cancels a pending invocation) + * + * @example + * const search = ref(''); + * watchDebounced(search, value => fetchResults(value), { debounce: 300 }); + * + * @example + * // Guarantee the callback runs at least every 1000ms while typing continuously + * watchDebounced(input, save, { debounce: 300, maxWait: 1000 }); + * + * @since 0.0.15 + */ +export function watchDebounced<T, Immediate extends Readonly<boolean> = false>( + source: WatchSource<T>, + cb: WatchCallback<T, Immediate extends true ? T | undefined : T>, + options?: WatchDebouncedOptions<Immediate>, +): WatchHandle; + +export function watchDebounced<T extends Readonly<MultiWatchSources>, Immediate extends Readonly<boolean> = false>( + sources: [...T], + cb: WatchCallback<MapSources<T>, MapOldSources<T, Immediate>>, + options?: WatchDebouncedOptions<Immediate>, +): WatchHandle; + +export function watchDebounced<T extends object, Immediate extends Readonly<boolean> = false>( + source: T, + cb: WatchCallback<T, Immediate extends true ? T | undefined : T>, + options?: WatchDebouncedOptions<Immediate>, +): WatchHandle; + +export function watchDebounced<Immediate extends Readonly<boolean> = false>( + source: WatchSource<unknown> | MultiWatchSources | object, + cb: WatchCallback, + options: WatchDebouncedOptions<Immediate> = {}, +): WatchHandle { + const { + debounce = 0, + maxWait, + eventFilter, + ...watchOptions + } = options; + + // Honour a caller-supplied eventFilter if present; otherwise build a + // debounce filter (with optional maxWait) from the timing options. + const filter: EventFilter = eventFilter + ?? debounceFilter(debounce, { maxWait }); + + return watch( + source, + createFilterWrapper(filter, cb), + watchOptions as WatchOptions<Immediate>, + ); +} + +export const debouncedWatch = watchDebounced; diff --git a/vue/toolkit/src/composables/reactivity/watchIgnorable/index.test.ts b/vue/toolkit/src/composables/reactivity/watchIgnorable/index.test.ts new file mode 100644 index 0000000..afac858 --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/watchIgnorable/index.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it, vi } from 'vitest'; +import { effectScope, nextTick, ref } from 'vue'; +import { watchIgnorable } from '.'; + +describe(watchIgnorable, () => { + it('fires the callback on normal updates (async flush)', async () => { + const count = ref(0); + const cb = vi.fn(); + watchIgnorable(count, cb); + + count.value = 1; + await nextTick(); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function)); + }); + + it('suppresses the callback inside ignoreUpdates (async flush)', async () => { + const count = ref(0); + const cb = vi.fn(); + const { ignoreUpdates } = watchIgnorable(count, cb); + + ignoreUpdates(() => { + count.value = 1; + }); + await nextTick(); + expect(cb).not.toHaveBeenCalled(); + + count.value = 2; + await nextTick(); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenLastCalledWith(2, 1, expect.any(Function)); + }); + + it('commits when an ignored update is followed by a real change before flush (async)', async () => { + const count = ref(0); + const cb = vi.fn(); + const { ignoreUpdates } = watchIgnorable(count, cb); + + ignoreUpdates(() => { + count.value = 1; + }); + // A real (non-ignored) change after the ignored one: syncCounter > ignoreCounter + count.value = 2; + await nextTick(); + + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenLastCalledWith(2, 0, expect.any(Function)); + }); + + it('supports multiple chained ignored updates (async)', async () => { + const count = ref(0); + const cb = vi.fn(); + const { ignoreUpdates } = watchIgnorable(count, cb); + + ignoreUpdates(() => { + count.value = 1; + count.value = 2; + count.value = 3; + }); + await nextTick(); + expect(cb).not.toHaveBeenCalled(); + }); + + it('fires the callback on normal updates (sync flush)', () => { + const count = ref(0); + const cb = vi.fn(); + watchIgnorable(count, cb, { flush: 'sync' }); + + count.value = 1; + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function)); + }); + + it('suppresses the callback inside ignoreUpdates (sync flush)', () => { + const count = ref(0); + const cb = vi.fn(); + const { ignoreUpdates } = watchIgnorable(count, cb, { flush: 'sync' }); + + ignoreUpdates(() => { + count.value = 1; + count.value = 2; + }); + expect(cb).not.toHaveBeenCalled(); + + count.value = 3; + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenLastCalledWith(3, 2, expect.any(Function)); + }); + + it('ignorePrevAsyncUpdates suppresses already-queued changes (async)', async () => { + const count = ref(0); + const cb = vi.fn(); + const { ignorePrevAsyncUpdates } = watchIgnorable(count, cb); + + count.value = 1; + // Drop the pending change before the async callback flushes + ignorePrevAsyncUpdates(); + await nextTick(); + expect(cb).not.toHaveBeenCalled(); + }); + + it('ignorePrevAsyncUpdates only drops prior changes, not subsequent ones (async)', async () => { + const count = ref(0); + const cb = vi.fn(); + const { ignorePrevAsyncUpdates } = watchIgnorable(count, cb); + + count.value = 1; + ignorePrevAsyncUpdates(); + count.value = 2; + await nextTick(); + + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenLastCalledWith(2, 0, expect.any(Function)); + }); + + it('ignorePrevAsyncUpdates is a no-op for sync flush', () => { + const count = ref(0); + const cb = vi.fn(); + const { ignorePrevAsyncUpdates } = watchIgnorable(count, cb, { flush: 'sync' }); + + count.value = 1; + expect(cb).toHaveBeenCalledTimes(1); + // Calling after a sync change does nothing + ignorePrevAsyncUpdates(); + count.value = 2; + expect(cb).toHaveBeenCalledTimes(2); + }); + + it('stop tears down the watcher (async flush)', async () => { + const count = ref(0); + const cb = vi.fn(); + const { stop } = watchIgnorable(count, cb); + + stop(); + count.value = 1; + await nextTick(); + expect(cb).not.toHaveBeenCalled(); + }); + + it('stop tears down the watcher (sync flush)', () => { + const count = ref(0); + const cb = vi.fn(); + const { stop } = watchIgnorable(count, cb, { flush: 'sync' }); + + stop(); + count.value = 1; + expect(cb).not.toHaveBeenCalled(); + }); + + it('watches an array of sources', async () => { + const a = ref(0); + const b = ref('x'); + const cb = vi.fn(); + const { ignoreUpdates } = watchIgnorable([a, b], cb); + + a.value = 1; + await nextTick(); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenLastCalledWith([1, 'x'], [0, 'x'], expect.any(Function)); + + ignoreUpdates(() => { + b.value = 'y'; + }); + await nextTick(); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('respects an eventFilter', async () => { + const count = ref(0); + const cb = vi.fn(); + // A filter that drops every invocation + watchIgnorable(count, cb, { eventFilter: () => {} }); + + count.value = 1; + await nextTick(); + expect(cb).not.toHaveBeenCalled(); + }); + + it('supports the immediate option', () => { + const count = ref(5); + const cb = vi.fn(); + watchIgnorable(count, cb, { immediate: true, flush: 'sync' }); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenLastCalledWith(5, undefined, expect.any(Function)); + }); + + it('stops with the owning effect scope', async () => { + const count = ref(0); + const cb = vi.fn(); + const scope = effectScope(); + + scope.run(() => watchIgnorable(count, cb)); + + count.value = 1; + await nextTick(); + expect(cb).toHaveBeenCalledTimes(1); + + scope.stop(); + count.value = 2; + await nextTick(); + expect(cb).toHaveBeenCalledTimes(1); + }); +}); diff --git a/vue/toolkit/src/composables/reactivity/watchIgnorable/index.ts b/vue/toolkit/src/composables/reactivity/watchIgnorable/index.ts new file mode 100644 index 0000000..e2dee6b --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/watchIgnorable/index.ts @@ -0,0 +1,170 @@ +import { watch } from 'vue'; +import type { WatchCallback, WatchOptions, WatchSource, WatchStopHandle } from 'vue'; +import { noop } from '@robonen/stdlib'; +import type { AnyFunction } from '@robonen/stdlib'; +import { bypassFilter, createFilterWrapper } from '@/utils'; +import type { ConfigurableEventFilter } from '@/utils'; + +type MultiWatchSources = Array<WatchSource<unknown> | object>; + +type MapSources<T> = { + [K in keyof T]: T[K] extends WatchSource<infer V> ? V : T[K] extends object ? T[K] : never; +}; + +type MapOldSources<T, Immediate> = { + [K in keyof T]: T[K] extends WatchSource<infer V> + ? Immediate extends true ? V | undefined : V + : T[K] extends object + ? Immediate extends true ? T[K] | undefined : T[K] + : never; +}; + +export interface WatchWithFilterOptions<Immediate> extends WatchOptions<Immediate>, ConfigurableEventFilter {} + +export type IgnoredUpdater = (updater: () => void) => void; +export type IgnoredPrevAsyncUpdates = () => void; + +export interface WatchIgnorableReturn { + /** + * Run `updater`, suppressing the watch callback for any source writes it performs + */ + ignoreUpdates: IgnoredUpdater; + /** + * Ignore the callback for source changes already queued before this call (async flush only) + */ + ignorePrevAsyncUpdates: IgnoredPrevAsyncUpdates; + /** + * Stop the underlying watcher(s) + */ + stop: WatchStopHandle; +} + +/** + * @name watchIgnorable + * @category Reactivity + * @description Extended `watch` that exposes `ignoreUpdates(fn)` and `ignorePrevAsyncUpdates()` to suppress reactions to programmatic writes. + * + * @param {WatchSource<T> | T} source The reactive source (ref, getter, reactive object, or array of sources) to watch + * @param {WatchCallback} cb Invoked with the new value, old value, and `onCleanup` when the source changes (unless ignored) + * @param {WatchWithFilterOptions} [options={}] Watch options (`immediate`, `deep`, `flush`) plus an optional `eventFilter` + * @returns {WatchIgnorableReturn} `{ ignoreUpdates, ignorePrevAsyncUpdates, stop }` + * + * @example + * const count = ref(0); + * const { ignoreUpdates } = watchIgnorable(count, value => console.log('changed', value)); + * + * count.value = 1; // logs: changed 1 + * ignoreUpdates(() => { + * count.value = 2; // does NOT log + * }); + * count.value = 3; // logs: changed 3 + * + * @since 0.0.15 + */ +export function watchIgnorable<T, Immediate extends Readonly<boolean> = false>( + source: WatchSource<T>, + cb: WatchCallback<T, Immediate extends true ? T | undefined : T>, + options?: WatchWithFilterOptions<Immediate>, +): WatchIgnorableReturn; + +export function watchIgnorable<T extends Readonly<MultiWatchSources>, Immediate extends Readonly<boolean> = false>( + sources: [...T], + cb: WatchCallback<MapSources<T>, MapOldSources<T, Immediate>>, + options?: WatchWithFilterOptions<Immediate>, +): WatchIgnorableReturn; + +export function watchIgnorable<T extends object, Immediate extends Readonly<boolean> = false>( + source: T, + cb: WatchCallback<T, Immediate extends true ? T | undefined : T>, + options?: WatchWithFilterOptions<Immediate>, +): WatchIgnorableReturn; + +export function watchIgnorable<Immediate extends Readonly<boolean> = false>( + source: any, + cb: AnyFunction, + options: WatchWithFilterOptions<Immediate> = {}, +): WatchIgnorableReturn { + const { eventFilter = bypassFilter, ...watchOptions } = options; + + const filteredCb = createFilterWrapper(eventFilter, cb); + + let ignoreUpdates: IgnoredUpdater; + let ignorePrevAsyncUpdates: IgnoredPrevAsyncUpdates; + let stop: WatchStopHandle; + + if (watchOptions.flush === 'sync') { + let ignore = false; + + // No async queue to drain with sync flush + ignorePrevAsyncUpdates = noop; + + ignoreUpdates = (updater: () => void) => { + ignore = true; + updater(); + ignore = false; + }; + + stop = watch( + source, + (...args: any[]) => { + if (!ignore) + filteredCb(...args); + }, + watchOptions, + ); + } + else { + // flush: 'pre' | 'post' + const disposables: WatchStopHandle[] = []; + + // `syncCounter` increments on every source change (tracked synchronously). + // `ignoreCounter` records how many of those changes should be suppressed. + // Comparing the two on the async callback lets us know whether the change + // came purely from an ignored update or includes a real modification. + let ignoreCounter = 0; + let syncCounter = 0; + + ignorePrevAsyncUpdates = () => { + ignoreCounter = syncCounter; + }; + + disposables.push( + watch( + source, + () => { + syncCounter++; + }, + { ...watchOptions, flush: 'sync' }, + ), + ); + + ignoreUpdates = (updater: () => void) => { + const syncCounterPrev = syncCounter; + updater(); + ignoreCounter += syncCounter - syncCounterPrev; + }; + + disposables.push( + watch( + source, + (...args: any[]) => { + const ignore = ignoreCounter > 0 && ignoreCounter === syncCounter; + ignoreCounter = 0; + syncCounter = 0; + + if (ignore) + return; + + filteredCb(...args); + }, + watchOptions, + ), + ); + + stop = () => { + for (const dispose of disposables) dispose(); + }; + } + + return { ignoreUpdates, ignorePrevAsyncUpdates, stop }; +} diff --git a/vue/toolkit/src/composables/reactivity/watchOnce/index.test.ts b/vue/toolkit/src/composables/reactivity/watchOnce/index.test.ts new file mode 100644 index 0000000..a5ec01b --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/watchOnce/index.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it, vi } from 'vitest'; +import { effectScope, nextTick, reactive, ref } from 'vue'; +import { watchOnce } from '.'; + +describe(watchOnce, () => { + it('does not fire before the source changes', () => { + const count = ref(0); + const cb = vi.fn(); + + watchOnce(count, cb, { flush: 'sync' }); + + expect(cb).not.toHaveBeenCalled(); + }); + + it('fires once on the first change', () => { + const count = ref(0); + const cb = vi.fn(); + + watchOnce(count, cb, { flush: 'sync' }); + + count.value = 1; + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function)); + }); + + it('auto-stops after the first trigger', () => { + const count = ref(0); + const cb = vi.fn(); + + watchOnce(count, cb, { flush: 'sync' }); + + count.value = 1; + count.value = 2; + count.value = 3; + + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function)); + }); + + it('works with a getter source', () => { + const count = ref(0); + const cb = vi.fn(); + + watchOnce(() => count.value * 2, cb, { flush: 'sync' }); + + count.value = 5; + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenLastCalledWith(10, 0, expect.any(Function)); + + count.value = 6; + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('works with an array of sources', () => { + const a = ref(0); + const b = ref('x'); + const cb = vi.fn(); + + watchOnce([a, b], cb, { flush: 'sync' }); + + a.value = 1; + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenLastCalledWith([1, 'x'], [0, 'x'], expect.any(Function)); + + b.value = 'y'; + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('works with a reactive object source and deep option', () => { + const state = reactive({ nested: { value: 0 } }); + const cb = vi.fn(); + + watchOnce(state, cb, { deep: true, flush: 'sync' }); + + state.nested.value = 1; + expect(cb).toHaveBeenCalledTimes(1); + + state.nested.value = 2; + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('fires immediately with the immediate option and then stops', () => { + const count = ref(0); + const cb = vi.fn(); + + watchOnce(count, cb, { immediate: true, flush: 'sync' }); + + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenLastCalledWith(0, undefined, expect.any(Function)); + + count.value = 1; + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('respects a custom flush timing', async () => { + const count = ref(0); + const cb = vi.fn(); + + watchOnce(count, cb, { flush: 'post' }); + + count.value = 1; + expect(cb).not.toHaveBeenCalled(); + + await nextTick(); + expect(cb).toHaveBeenCalledTimes(1); + + count.value = 2; + await nextTick(); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('returns a handle that stops watching before the first trigger', () => { + const count = ref(0); + const cb = vi.fn(); + + const stop = watchOnce(count, cb, { flush: 'sync' }); + + stop(); + + count.value = 1; + expect(cb).not.toHaveBeenCalled(); + }); + + it('stops watching when the owning scope is disposed', () => { + const count = ref(0); + const cb = vi.fn(); + const scope = effectScope(); + + scope.run(() => watchOnce(count, cb, { flush: 'sync' })); + + scope.stop(); + + count.value = 1; + expect(cb).not.toHaveBeenCalled(); + }); + + it('passes an onCleanup function to the callback', () => { + const count = ref(0); + const cb = vi.fn(); + + watchOnce(count, cb, { flush: 'sync' }); + + count.value = 1; + expect(cb).toHaveBeenCalledTimes(1); + expect(cb.mock.calls[0]![2]).toBeTypeOf('function'); + }); +}); diff --git a/vue/toolkit/src/composables/reactivity/watchOnce/index.ts b/vue/toolkit/src/composables/reactivity/watchOnce/index.ts new file mode 100644 index 0000000..2e4c9b8 --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/watchOnce/index.ts @@ -0,0 +1,65 @@ +import { watch } from 'vue'; +import type { WatchCallback, WatchHandle, WatchOptions, WatchSource } from 'vue'; + +type MultiWatchSources = Array<WatchSource<unknown> | object>; + +type MapSources<T> = { + [K in keyof T]: T[K] extends WatchSource<infer V> ? V : T[K] extends object ? T[K] : never; +}; + +type MapOldSources<T, Immediate> = { + [K in keyof T]: T[K] extends WatchSource<infer V> + ? Immediate extends true ? V | undefined : V + : T[K] extends object + ? Immediate extends true ? T[K] | undefined : T[K] + : never; +}; + +export type WatchOnceOptions<Immediate = boolean> = Omit<WatchOptions<Immediate>, 'once'>; + +/** + * @name watchOnce + * @category Reactivity + * @description Shorthand for `watch` that automatically stops after the callback fires once. + * + * @param {WatchSource<T> | T} source The reactive source (ref, getter, reactive object, or array of sources) to watch + * @param {WatchCallback} cb Invoked once with the new value, old value, and `onCleanup` + * @param {WatchOnceOptions} [options] Watch options (`immediate`, `deep`, `flush`); `once` is forced on + * @returns {WatchHandle} A handle to stop watching before the first trigger + * + * @example + * const count = ref(0); + * watchOnce(count, value => console.log('fired once with', value)); + * + * @example + * watchOnce([a, b], ([a, b]) => console.log(a, b)); + * + * @since 0.0.15 + */ +export function watchOnce<T>( + source: WatchSource<T>, + cb: WatchCallback<T, T | undefined>, + options?: WatchOnceOptions<true>, +): WatchHandle; + +export function watchOnce<T extends Readonly<MultiWatchSources>>( + source: [...T], + cb: WatchCallback<MapSources<T>, MapOldSources<T, true>>, + options?: WatchOnceOptions<true>, +): WatchHandle; + +export function watchOnce<T extends object>( + source: T, + cb: WatchCallback<T, T | undefined>, + options?: WatchOnceOptions<true>, +): WatchHandle; + +export function watchOnce( + source: WatchSource<unknown> | MultiWatchSources | object, + cb: WatchCallback, + options?: WatchOnceOptions, +): WatchHandle { + // Vue's native `once` stops the watcher after its first trigger (the + // immediate run counts as that trigger), so no manual teardown is needed. + return watch(source, cb, { ...options, once: true }); +} diff --git a/vue/toolkit/src/composables/reactivity/watchPausable/index.test.ts b/vue/toolkit/src/composables/reactivity/watchPausable/index.test.ts new file mode 100644 index 0000000..582773e --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/watchPausable/index.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it, vi } from 'vitest'; +import { effectScope, isReadonly, nextTick, reactive, ref } from 'vue'; +import { pausableWatch, watchPausable } from '.'; +import { debounceFilter } from '@/utils/filters'; + +describe(watchPausable, () => { + it('invokes the callback on source change when active', async () => { + const scope = effectScope(); + await scope.run(async () => { + const count = ref(0); + const cb = vi.fn(); + watchPausable(count, cb); + + count.value = 1; + await nextTick(); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenLastCalledWith(1, 0, expect.anything()); + }); + scope.stop(); + }); + + it('starts active by default', () => { + const scope = effectScope(); + scope.run(() => { + const { isActive } = watchPausable(ref(0), () => {}); + expect(isActive.value).toBeTruthy(); + }); + scope.stop(); + }); + + it('does not invoke the callback while paused', async () => { + const scope = effectScope(); + await scope.run(async () => { + const count = ref(0); + const cb = vi.fn(); + const { pause } = watchPausable(count, cb); + + pause(); + count.value = 1; + await nextTick(); + expect(cb).not.toHaveBeenCalled(); + }); + scope.stop(); + }); + + it('isActive reflects pause/resume', () => { + const scope = effectScope(); + scope.run(() => { + const { pause, resume, isActive } = watchPausable(ref(0), () => {}); + + expect(isActive.value).toBeTruthy(); + pause(); + expect(isActive.value).toBeFalsy(); + resume(); + expect(isActive.value).toBeTruthy(); + }); + scope.stop(); + }); + + it('resumes reacting to changes after resume', async () => { + const scope = effectScope(); + await scope.run(async () => { + const count = ref(0); + const cb = vi.fn(); + const { pause, resume } = watchPausable(count, cb); + + pause(); + count.value = 1; + await nextTick(); + expect(cb).not.toHaveBeenCalled(); + + resume(); + count.value = 2; + await nextTick(); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenLastCalledWith(2, 1, expect.anything()); + }); + scope.stop(); + }); + + it('does not replay changes that happened while paused', async () => { + const scope = effectScope(); + await scope.run(async () => { + const count = ref(0); + const cb = vi.fn(); + const { pause, resume } = watchPausable(count, cb); + + pause(); + count.value = 1; + count.value = 2; + await nextTick(); + resume(); + await nextTick(); + + // Resume alone must not fire the callback for the missed changes. + expect(cb).not.toHaveBeenCalled(); + }); + scope.stop(); + }); + + it('respects initialState: paused', async () => { + const scope = effectScope(); + await scope.run(async () => { + const count = ref(0); + const cb = vi.fn(); + const { isActive, resume } = watchPausable(count, cb, { initialState: 'paused' }); + + expect(isActive.value).toBeFalsy(); + count.value = 1; + await nextTick(); + expect(cb).not.toHaveBeenCalled(); + + resume(); + count.value = 2; + await nextTick(); + expect(cb).toHaveBeenCalledTimes(1); + }); + scope.stop(); + }); + + it('stop() halts the watcher permanently', async () => { + const scope = effectScope(); + await scope.run(async () => { + const count = ref(0); + const cb = vi.fn(); + const { stop, resume } = watchPausable(count, cb); + + stop(); + count.value = 1; + await nextTick(); + expect(cb).not.toHaveBeenCalled(); + + // resume cannot revive a stopped watcher + resume(); + count.value = 2; + await nextTick(); + expect(cb).not.toHaveBeenCalled(); + }); + scope.stop(); + }); + + it('returns a readonly isActive ref', () => { + const scope = effectScope(); + scope.run(() => { + const { isActive } = watchPausable(ref(0), () => {}); + expect(isReadonly(isActive)).toBeTruthy(); + }); + scope.stop(); + }); + + it('supports multiple sources', async () => { + const scope = effectScope(); + await scope.run(async () => { + const a = ref(0); + const b = ref('x'); + const cb = vi.fn(); + watchPausable([a, b], cb); + + a.value = 1; + await nextTick(); + expect(cb).toHaveBeenLastCalledWith([1, 'x'], [0, 'x'], expect.anything()); + + b.value = 'y'; + await nextTick(); + expect(cb).toHaveBeenLastCalledWith([1, 'y'], [1, 'x'], expect.anything()); + }); + scope.stop(); + }); + + it('supports a getter source', async () => { + const scope = effectScope(); + await scope.run(async () => { + const state = reactive({ n: 1 }); + const cb = vi.fn(); + watchPausable(() => state.n, cb); + + state.n = 2; + await nextTick(); + expect(cb).toHaveBeenLastCalledWith(2, 1, expect.anything()); + }); + scope.stop(); + }); + + it('supports a reactive object source with deep', async () => { + const scope = effectScope(); + await scope.run(async () => { + const state = reactive({ nested: { n: 1 } }); + const cb = vi.fn(); + watchPausable(state, cb, { deep: true }); + + state.nested.n = 2; + await nextTick(); + expect(cb).toHaveBeenCalledTimes(1); + }); + scope.stop(); + }); + + it('fires synchronously with flush: sync', () => { + const scope = effectScope(); + scope.run(() => { + const count = ref(0); + const cb = vi.fn(); + watchPausable(count, cb, { flush: 'sync' }); + + count.value = 1; + expect(cb).toHaveBeenCalledTimes(1); + }); + scope.stop(); + }); + + it('honors immediate option', () => { + const scope = effectScope(); + scope.run(() => { + const count = ref(0); + const cb = vi.fn(); + watchPausable(count, cb, { immediate: true, flush: 'sync' }); + + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenLastCalledWith(0, undefined, expect.anything()); + }); + scope.stop(); + }); + + it('composes with a custom eventFilter (debounce)', async () => { + vi.useFakeTimers(); + const scope = effectScope(); + scope.run(() => { + const count = ref(0); + const cb = vi.fn(); + const { pause } = watchPausable(count, cb, { + eventFilter: debounceFilter(100), + flush: 'sync', + }); + + count.value = 1; + count.value = 2; + expect(cb).not.toHaveBeenCalled(); + vi.advanceTimersByTime(100); + expect(cb).toHaveBeenCalledTimes(1); + + // While paused the filter must not even be reached. + pause(); + count.value = 3; + vi.advanceTimersByTime(100); + expect(cb).toHaveBeenCalledTimes(1); + }); + scope.stop(); + vi.useRealTimers(); + }); + + it('pausableWatch is an alias for watchPausable', () => { + expect(pausableWatch).toBe(watchPausable); + }); + + it('works outside an effect scope (SSR-style, manual stop)', async () => { + const count = ref(0); + const cb = vi.fn(); + const { stop } = watchPausable(count, cb); + + count.value = 1; + await nextTick(); + expect(cb).toHaveBeenCalledTimes(1); + stop(); + }); +}); diff --git a/vue/toolkit/src/composables/reactivity/watchPausable/index.ts b/vue/toolkit/src/composables/reactivity/watchPausable/index.ts new file mode 100644 index 0000000..aa557df --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/watchPausable/index.ts @@ -0,0 +1,126 @@ +import { ref, shallowReadonly, watch } from 'vue'; +import type { + MultiWatchSources, + Ref, + WatchCallback, + WatchOptions, + WatchSource, + WatchStopHandle, +} from 'vue'; +import { bypassFilter, createFilterWrapper } from '@/utils/filters'; +import type { ConfigurableEventFilter, EventFilter } from '@/utils/filters'; + +type MapSources<T> = { + [K in keyof T]: T[K] extends WatchSource<infer V> ? V : never; +}; + +type MapOldSources<T, Immediate> = { + [K in keyof T]: T[K] extends WatchSource<infer V> + ? Immediate extends true ? V | undefined : V + : never; +}; + +export interface UseWatchPausableOptions<Immediate> + extends WatchOptions<Immediate>, ConfigurableEventFilter { + /** + * Whether the watcher starts in an active (running) or paused state. + * + * @default 'active' + */ + initialState?: 'active' | 'paused'; +} + +export interface UseWatchPausableReturn { + /** + * Whether the watcher is currently active. While `false`, source changes are + * ignored and the callback is not invoked. + */ + isActive: Readonly<Ref<boolean>>; + /** + * Pause the watcher. Changes to the source are ignored until {@link resume}. + */ + pause: () => void; + /** + * Resume the watcher so it reacts to source changes again. + */ + resume: () => void; + /** + * Stop the watcher entirely. It cannot be restarted afterwards. + */ + stop: WatchStopHandle; +} + +/** + * @name watchPausable + * @category Reactivity + * @description A `watch` whose execution can be paused and resumed on demand via a pausable event filter. + * + * @param {WatchSource | WatchSource[] | object} source The watch source (ref, getter, reactive object, or an array of sources) + * @param {WatchCallback} cb The callback invoked when an active source changes + * @param {UseWatchPausableOptions} [options={}] Watch options plus `eventFilter` and `initialState` + * @returns {UseWatchPausableReturn} `{ stop, pause, resume, isActive }` + * + * @example + * const count = ref(0); + * const { pause, resume, isActive } = watchPausable(count, (value) => { + * console.log('changed to', value); + * }); + * + * pause(); + * count.value++; // callback not called + * resume(); + * count.value++; // callback called + * + * @since 0.0.15 + */ +export function watchPausable<T extends Readonly<MultiWatchSources>, Immediate extends Readonly<boolean> = false>( + sources: [...T], + cb: WatchCallback<MapSources<T>, MapOldSources<T, Immediate>>, + options?: UseWatchPausableOptions<Immediate>, +): UseWatchPausableReturn; +export function watchPausable<T, Immediate extends Readonly<boolean> = false>( + source: WatchSource<T>, + cb: WatchCallback<T, Immediate extends true ? T | undefined : T>, + options?: UseWatchPausableOptions<Immediate>, +): UseWatchPausableReturn; +export function watchPausable<T extends object, Immediate extends Readonly<boolean> = false>( + source: T, + cb: WatchCallback<T, Immediate extends true ? T | undefined : T>, + options?: UseWatchPausableOptions<Immediate>, +): UseWatchPausableReturn; +export function watchPausable<Immediate extends Readonly<boolean> = false>( + source: any, + cb: any, + options: UseWatchPausableOptions<Immediate> = {}, +): UseWatchPausableReturn { + const { + eventFilter: filter = bypassFilter, + initialState = 'active', + ...watchOptions + } = options; + + const isActive = ref(initialState !== 'paused'); + + const eventFilter: EventFilter = (invoke) => { + if (isActive.value) + filter(invoke); + }; + + const stop = watch( + source, + createFilterWrapper(eventFilter, cb), + watchOptions, + ); + + return { + isActive: shallowReadonly(isActive), + pause: () => { isActive.value = false; }, + resume: () => { isActive.value = true; }, + stop, + }; +} + +/** + * Alias for {@link watchPausable}. + */ +export const pausableWatch = watchPausable; diff --git a/vue/toolkit/src/composables/reactivity/watchThrottled/index.test.ts b/vue/toolkit/src/composables/reactivity/watchThrottled/index.test.ts new file mode 100644 index 0000000..5348041 --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/watchThrottled/index.test.ts @@ -0,0 +1,214 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, nextTick, reactive, ref } from 'vue'; +import { watchThrottled } from '.'; + +describe(watchThrottled, () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it('does not fire before the source changes', async () => { + const count = ref(0); + const cb = vi.fn(); + + watchThrottled(count, cb, { throttle: 100 }); + + await nextTick(); + expect(cb).not.toHaveBeenCalled(); + }); + + it('fires immediately on the leading edge', async () => { + const count = ref(0); + const cb = vi.fn(); + + watchThrottled(count, cb, { throttle: 100, flush: 'sync' }); + + count.value = 1; + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function)); + }); + + it('throttles rapid changes to one leading + one trailing call', async () => { + const count = ref(0); + const cb = vi.fn(); + + watchThrottled(count, cb, { throttle: 100, flush: 'sync' }); + + count.value = 1; // leading -> fires with 1 + count.value = 2; + count.value = 3; // scheduled trailing -> fires with latest (3) + + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function)); + + await vi.advanceTimersByTimeAsync(100); + + expect(cb).toHaveBeenCalledTimes(2); + expect(cb).toHaveBeenLastCalledWith(3, 2, expect.any(Function)); + }); + + it('suppresses the leading call when leading is false', async () => { + const count = ref(0); + const cb = vi.fn(); + + watchThrottled(count, cb, { throttle: 100, leading: false, flush: 'sync' }); + + count.value = 1; + expect(cb).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(100); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function)); + }); + + it('suppresses the trailing call when trailing is false', async () => { + const count = ref(0); + const cb = vi.fn(); + + watchThrottled(count, cb, { throttle: 100, trailing: false, flush: 'sync' }); + + count.value = 1; // leading + count.value = 2; + count.value = 3; + + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function)); + + await vi.advanceTimersByTimeAsync(200); + + // no trailing invocation + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('behaves like a plain watch when throttle is 0', async () => { + const count = ref(0); + const cb = vi.fn(); + + watchThrottled(count, cb, { throttle: 0, flush: 'sync' }); + + count.value = 1; + count.value = 2; + count.value = 3; + + expect(cb).toHaveBeenCalledTimes(3); + expect(cb).toHaveBeenLastCalledWith(3, 2, expect.any(Function)); + }); + + it('works with a getter source', async () => { + const count = ref(0); + const cb = vi.fn(); + + watchThrottled(() => count.value * 2, cb, { throttle: 100, flush: 'sync' }); + + count.value = 5; + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenLastCalledWith(10, 0, expect.any(Function)); + }); + + it('works with an array of sources', async () => { + const a = ref(0); + const b = ref('x'); + const cb = vi.fn(); + + watchThrottled([a, b], cb, { throttle: 100, flush: 'sync' }); + + a.value = 1; + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenLastCalledWith([1, 'x'], [0, 'x'], expect.any(Function)); + }); + + it('works with a reactive object source and deep option', async () => { + const state = reactive({ nested: { value: 0 } }); + const cb = vi.fn(); + + watchThrottled(state, cb, { throttle: 100, deep: true, flush: 'sync' }); + + state.nested.value = 1; + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('honors a reactive throttle interval', async () => { + const count = ref(0); + const interval = ref(100); + const cb = vi.fn(); + + watchThrottled(count, cb, { throttle: interval, flush: 'sync' }); + + count.value = 1; // leading at t=0 + count.value = 2; // schedules trailing at t=100 + expect(cb).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(100); + expect(cb).toHaveBeenCalledTimes(2); + }); + + it('respects a post flush timing', async () => { + const count = ref(0); + const cb = vi.fn(); + + watchThrottled(count, cb, { throttle: 100, flush: 'post' }); + + count.value = 1; + expect(cb).not.toHaveBeenCalled(); + + await nextTick(); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('fires immediately with the immediate option', async () => { + const count = ref(5); + const cb = vi.fn(); + + watchThrottled(count, cb, { throttle: 100, immediate: true, flush: 'sync' }); + + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenLastCalledWith(5, undefined, expect.any(Function)); + }); + + it('returns a handle that stops watching', async () => { + const count = ref(0); + const cb = vi.fn(); + + const stop = watchThrottled(count, cb, { throttle: 100, flush: 'sync' }); + + stop(); + + count.value = 1; + await vi.advanceTimersByTimeAsync(100); + expect(cb).not.toHaveBeenCalled(); + }); + + it('stops watching when the owning scope is disposed', async () => { + const count = ref(0); + const cb = vi.fn(); + const scope = effectScope(); + + scope.run(() => watchThrottled(count, cb, { throttle: 100, flush: 'sync' })); + + scope.stop(); + + count.value = 1; + await vi.advanceTimersByTimeAsync(100); + expect(cb).not.toHaveBeenCalled(); + }); + + it('passes onCleanup to the callback', async () => { + const count = ref(0); + const cleanup = vi.fn(); + + watchThrottled(count, (_value, _old, onCleanup) => { + onCleanup(cleanup); + }, { throttle: 100, flush: 'sync' }); + + count.value = 1; + count.value = 2; // schedules trailing, which triggers cleanup of the leading run + + await vi.advanceTimersByTimeAsync(100); + expect(cleanup).toHaveBeenCalled(); + }); +}); diff --git a/vue/toolkit/src/composables/reactivity/watchThrottled/index.ts b/vue/toolkit/src/composables/reactivity/watchThrottled/index.ts new file mode 100644 index 0000000..65f033a --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/watchThrottled/index.ts @@ -0,0 +1,96 @@ +import { watch } from 'vue'; +import type { MaybeRefOrGetter, WatchCallback, WatchHandle, WatchOptions, WatchSource } from 'vue'; +import { createFilterWrapper, throttleFilter } from '@/utils/filters'; +import type { EventFilter } from '@/utils/filters'; + +type MultiWatchSources = Array<WatchSource<unknown> | object>; + +type MapSources<T> = { + [K in keyof T]: T[K] extends WatchSource<infer V> ? V : T[K] extends object ? T[K] : never; +}; + +type MapOldSources<T, Immediate> = { + [K in keyof T]: T[K] extends WatchSource<infer V> + ? Immediate extends true ? V | undefined : V + : T[K] extends object + ? Immediate extends true ? T[K] | undefined : T[K] + : never; +}; + +export interface WatchThrottledOptions<Immediate> extends WatchOptions<Immediate> { + /** + * Throttle interval in milliseconds (can be reactive) + * + * @default 0 + */ + throttle?: MaybeRefOrGetter<number>; + /** + * Invoke the callback on the trailing edge of the interval + * + * @default true + */ + trailing?: boolean; + /** + * Invoke the callback on the leading edge of the interval + * + * @default true + */ + leading?: boolean; +} + +/** + * @name watchThrottled + * @category Reactivity + * @description Like `watch`, but throttles the callback so it fires at most once per interval. + * + * @param {WatchSource<T> | T} source The reactive source (ref, getter, reactive object, or array of sources) to watch + * @param {WatchCallback} cb Invoked with the new value, old value, and `onCleanup`, throttled by the interval + * @param {WatchThrottledOptions} [options] Watch options plus `throttle` (ms), `leading`, and `trailing` + * @returns {WatchHandle} A handle to stop watching + * + * @example + * const count = ref(0); + * watchThrottled(count, value => console.log(value), { throttle: 500 }); + * + * @example + * watchThrottled([a, b], ([a, b]) => save(a, b), { throttle: 1000, leading: false }); + * + * @since 0.0.15 + */ +export function watchThrottled<T, Immediate extends Readonly<boolean> = false>( + source: WatchSource<T>, + cb: WatchCallback<T, Immediate extends true ? T | undefined : T>, + options?: WatchThrottledOptions<Immediate>, +): WatchHandle; + +export function watchThrottled<T extends Readonly<MultiWatchSources>, Immediate extends Readonly<boolean> = false>( + source: [...T], + cb: WatchCallback<MapSources<T>, MapOldSources<T, Immediate>>, + options?: WatchThrottledOptions<Immediate>, +): WatchHandle; + +export function watchThrottled<T extends object, Immediate extends Readonly<boolean> = false>( + source: T, + cb: WatchCallback<T, Immediate extends true ? T | undefined : T>, + options?: WatchThrottledOptions<Immediate>, +): WatchHandle; + +export function watchThrottled( + source: WatchSource<unknown> | MultiWatchSources | object, + cb: WatchCallback, + options: WatchThrottledOptions<boolean> = {}, +): WatchHandle { + const { + throttle = 0, + trailing = true, + leading = true, + eventFilter = throttleFilter(throttle, trailing, leading), + ...watchOptions + } = options as WatchThrottledOptions<boolean> & { eventFilter?: EventFilter }; + + return watch( + source, + createFilterWrapper(eventFilter, cb), + watchOptions, + ); +} diff --git a/vue/toolkit/src/composables/reactivity/whenever/index.test.ts b/vue/toolkit/src/composables/reactivity/whenever/index.test.ts new file mode 100644 index 0000000..1727ba5 --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/whenever/index.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it, vi } from 'vitest'; +import { effectScope, nextTick, reactive, ref } from 'vue'; +import { whenever } from '.'; + +describe(whenever, () => { + it('does not fire while the source is falsy', () => { + const ready = ref(false); + const cb = vi.fn(); + + whenever(ready, cb, { flush: 'sync' }); + + expect(cb).not.toHaveBeenCalled(); + }); + + it('fires when the source becomes truthy', () => { + const ready = ref(false); + const cb = vi.fn(); + + whenever(ready, cb, { flush: 'sync' }); + + ready.value = true; + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenLastCalledWith(true, false, expect.any(Function)); + }); + + it('does not fire when the source becomes falsy again', () => { + const ready = ref(true); + const cb = vi.fn(); + + whenever(ready, cb, { flush: 'sync' }); + + ready.value = false; + expect(cb).not.toHaveBeenCalled(); + + ready.value = true; + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('fires repeatedly on each truthy transition', () => { + const value = ref(0); + const cb = vi.fn(); + + whenever(value, cb, { flush: 'sync' }); + + value.value = 1; + value.value = 0; + value.value = 2; + value.value = 0; + value.value = 3; + + expect(cb).toHaveBeenCalledTimes(3); + }); + + it('works with a getter source', () => { + const count = ref(0); + const cb = vi.fn(); + + whenever(() => count.value > 5, cb, { flush: 'sync' }); + + count.value = 3; + expect(cb).not.toHaveBeenCalled(); + + count.value = 10; + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenLastCalledWith(true, false, expect.any(Function)); + }); + + it('fires immediately when source is already truthy with immediate', () => { + const ready = ref(true); + const cb = vi.fn(); + + whenever(ready, cb, { immediate: true, flush: 'sync' }); + + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenLastCalledWith(true, undefined, expect.any(Function)); + }); + + it('does not fire immediately when source is falsy with immediate', () => { + const ready = ref(false); + const cb = vi.fn(); + + whenever(ready, cb, { immediate: true, flush: 'sync' }); + + expect(cb).not.toHaveBeenCalled(); + }); + + it('only fires once with the once option', async () => { + const value = ref(0); + const cb = vi.fn(); + + whenever(value, cb, { once: true, flush: 'sync' }); + + value.value = 1; + expect(cb).toHaveBeenCalledTimes(1); + + // once schedules teardown on the next tick + await nextTick(); + + value.value = 0; + value.value = 2; + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('tracks deep mutations with the deep option', () => { + const state = reactive({ active: false }); + const cb = vi.fn(); + + whenever(() => state.active, cb, { deep: true, flush: 'sync' }); + + state.active = true; + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('respects a custom flush timing', async () => { + const ready = ref(false); + const cb = vi.fn(); + + whenever(ready, cb, { flush: 'post' }); + + ready.value = true; + // post flush is deferred until after the next tick + expect(cb).not.toHaveBeenCalled(); + + await nextTick(); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('returns a handle that stops watching when called', () => { + const ready = ref(false); + const cb = vi.fn(); + + const stop = whenever(ready, cb, { flush: 'sync' }); + + stop(); + + ready.value = true; + expect(cb).not.toHaveBeenCalled(); + }); + + it('stops watching when the owning scope is disposed', () => { + const ready = ref(false); + const cb = vi.fn(); + const scope = effectScope(); + + scope.run(() => whenever(ready, cb, { flush: 'sync' })); + + ready.value = true; + expect(cb).toHaveBeenCalledTimes(1); + + scope.stop(); + + ready.value = false; + ready.value = true; + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('passes the truthy value to the callback for non-boolean sources', () => { + const user = ref<{ id: number } | null>(null); + const cb = vi.fn(); + + whenever(user, cb, { flush: 'sync' }); + + const next = { id: 1 }; + user.value = next; + expect(cb).toHaveBeenLastCalledWith(next, null, expect.any(Function)); + }); +}); diff --git a/vue/toolkit/src/composables/reactivity/whenever/index.ts b/vue/toolkit/src/composables/reactivity/whenever/index.ts new file mode 100644 index 0000000..d5ee321 --- /dev/null +++ b/vue/toolkit/src/composables/reactivity/whenever/index.ts @@ -0,0 +1,60 @@ +import { nextTick, watch } from 'vue'; +import type { WatchCallback, WatchHandle, WatchOptions, WatchSource } from 'vue'; + +type Truthy<T> = T extends false | null | undefined | 0 | '' ? never : T; + +export interface WheneverOptions<Immediate = boolean> extends WatchOptions<Immediate> { + /** + * Only trigger the callback once when the source becomes truthy. + * + * Overrides the `once` option inherited from `WatchOptions`. + * + * @default false + */ + once?: boolean; +} + +/** + * @name whenever + * @category Reactivity + * @description Shorthand for watching a source to be truthy. Behaves like `watch`, but the callback only fires when the resolved value is truthy. + * + * @param {WatchSource<T>} source The reactive source to watch + * @param {WatchCallback} cb Invoked with the truthy value, previous value, and `onCleanup` + * @param {WheneverOptions} [options] Watch options (`immediate`, `deep`, `flush`, `once`) + * @returns {WatchHandle} A handle to stop watching + * + * @example + * const ready = ref(false); + * whenever(ready, () => console.log('ready!')); + * + * @example + * whenever(() => count.value > 5, () => console.log('over five'), { once: true }); + * + * @since 0.0.15 + */ +export function whenever<T>(source: WatchSource<T>, cb: WatchCallback<Truthy<T>, T | undefined>, options?: WheneverOptions<true>): WatchHandle; +export function whenever<T>(source: WatchSource<T>, cb: WatchCallback<Truthy<T>, T>, options?: WheneverOptions<false>): WatchHandle; +export function whenever<T>( + source: WatchSource<T>, + cb: WatchCallback<Truthy<T>, T | undefined>, + options?: WheneverOptions, +): WatchHandle { + const stop = watch( + source, + (value, oldValue, onCleanup) => { + if (value) { + if (options?.once) + nextTick(() => stop()); + + cb(value as Truthy<T>, oldValue, onCleanup); + } + }, + { + ...options, + once: false, + } as WatchOptions, + ); + + return stop; +} diff --git a/vue/toolkit/src/composables/state/index.ts b/vue/toolkit/src/composables/state/index.ts index bf2c4fe..bd2b1bc 100644 --- a/vue/toolkit/src/composables/state/index.ts +++ b/vue/toolkit/src/composables/state/index.ts @@ -2,5 +2,7 @@ export * from './useAppSharedState'; export * from './useAsyncState'; export * from './useContextFactory'; export * from './useCounter'; +export * from './useId'; export * from './useInjectionStore'; +export * from './useStepper'; export * from './useToggle'; diff --git a/vue/toolkit/src/composables/state/useAppSharedState/index.test.ts b/vue/toolkit/src/composables/state/useAppSharedState/index.test.ts index 7b0ffaa..381e7f2 100644 --- a/vue/toolkit/src/composables/state/useAppSharedState/index.test.ts +++ b/vue/toolkit/src/composables/state/useAppSharedState/index.test.ts @@ -1,5 +1,5 @@ -import { describe, it, vi, expect } from 'vitest'; -import { ref, reactive } from 'vue'; +import { describe, expect, it, vi } from 'vitest'; +import { reactive, ref } from 'vue'; import { useAppSharedState } from '.'; describe(useAppSharedState, () => { diff --git a/vue/toolkit/src/composables/state/useAsyncState/index.test.ts b/vue/toolkit/src/composables/state/useAsyncState/index.test.ts index 1a669bd..b27ee02 100644 --- a/vue/toolkit/src/composables/state/useAsyncState/index.test.ts +++ b/vue/toolkit/src/composables/state/useAsyncState/index.test.ts @@ -1,5 +1,5 @@ import { isShallow, nextTick, ref } from 'vue'; -import { it, expect, describe, vi, beforeEach, afterEach } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useAsyncState } from '.'; describe(useAsyncState, () => { diff --git a/vue/toolkit/src/composables/state/useContextFactory/index.test.ts b/vue/toolkit/src/composables/state/useContextFactory/index.test.ts index 356d0c5..8a43a2b 100644 --- a/vue/toolkit/src/composables/state/useContextFactory/index.test.ts +++ b/vue/toolkit/src/composables/state/useContextFactory/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { defineComponent } from 'vue'; import { useContextFactory } from '.'; import { mount } from '@vue/test-utils'; diff --git a/vue/toolkit/src/composables/state/useContextFactory/index.ts b/vue/toolkit/src/composables/state/useContextFactory/index.ts index 1028a32..ca4d1a4 100644 --- a/vue/toolkit/src/composables/state/useContextFactory/index.ts +++ b/vue/toolkit/src/composables/state/useContextFactory/index.ts @@ -1,5 +1,5 @@ import { inject as vueInject, provide as vueProvide } from 'vue'; -import type { InjectionKey, App } from 'vue'; +import type { App, InjectionKey } from 'vue'; import { VueToolsError } from '@/utils'; /** diff --git a/vue/toolkit/src/composables/state/useCounter/demo.vue b/vue/toolkit/src/composables/state/useCounter/demo.vue index d41be7d..fe65768 100644 --- a/vue/toolkit/src/composables/state/useCounter/demo.vue +++ b/vue/toolkit/src/composables/state/useCounter/demo.vue @@ -3,4 +3,4 @@ <template> <div> </div> -</template> \ No newline at end of file +</template> diff --git a/vue/toolkit/src/composables/state/useCounter/index.test.ts b/vue/toolkit/src/composables/state/useCounter/index.test.ts index 3c6bc2c..a2040a7 100644 --- a/vue/toolkit/src/composables/state/useCounter/index.test.ts +++ b/vue/toolkit/src/composables/state/useCounter/index.test.ts @@ -1,4 +1,4 @@ -import { it, expect, describe } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { ref } from 'vue'; import { useCounter } from '.'; diff --git a/vue/toolkit/src/composables/state/useId/index.test.ts b/vue/toolkit/src/composables/state/useId/index.test.ts new file mode 100644 index 0000000..2663be4 --- /dev/null +++ b/vue/toolkit/src/composables/state/useId/index.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { defineComponent, h, ref } from 'vue'; +import { mount } from '@vue/test-utils'; +import { useId } from '.'; + +function mountWithId(deterministic?: () => string | undefined, prefix?: string) { + const Comp = defineComponent({ + setup() { + const id = useId(deterministic, prefix); + return () => h('span', { 'data-id': id.value }, id.value); + }, + }); + + return mount(Comp); +} + +describe(useId, () => { + it('returns a non-empty string', () => { + const w = mountWithId(); + expect(w.text()).toMatch(/^robonen-/); + w.unmount(); + }); + + it('uses custom prefix', () => { + const w = mountWithId(undefined, 'dialog'); + expect(w.text()).toMatch(/^dialog-/); + w.unmount(); + }); + + it('returns deterministic value when provided', () => { + const w = mountWithId(() => 'custom-id'); + expect(w.text()).toBe('custom-id'); + w.unmount(); + }); + + it('falls back to generated when deterministic is empty', () => { + const w = mountWithId(() => '', 'x'); + expect(w.text()).toMatch(/^x-/); + w.unmount(); + }); + + it('is reactive to deterministic input changes', async () => { + const d = ref<string | undefined>(undefined); + + const Comp = defineComponent({ + setup() { + const id = useId(() => d.value, 'p'); + return () => h('span', id.value); + }, + }); + + const w = mount(Comp); + expect(w.text()).toMatch(/^p-/); + + d.value = 'forced'; + await w.vm.$nextTick(); + + expect(w.text()).toBe('forced'); + w.unmount(); + }); +}); diff --git a/vue/toolkit/src/composables/state/useId/index.ts b/vue/toolkit/src/composables/state/useId/index.ts new file mode 100644 index 0000000..ae4f460 --- /dev/null +++ b/vue/toolkit/src/composables/state/useId/index.ts @@ -0,0 +1,32 @@ +import type { ComputedRef, MaybeRefOrGetter } from 'vue'; +import { computed, toValue, useId as vueUseId } from 'vue'; + +/** + * @name useId + * @category State + * @description SSR-safe unique identifier. Thin wrapper around Vue 3.5's built-in `useId()` + * that accepts an optional prefix and allows callers to pass a pre-existing id + * (useful for primitives that accept a user-supplied `id` prop). + * + * @param {MaybeRefOrGetter<string | undefined>} [deterministic] Existing id to return if provided + * @param {string} [prefix='robonen'] Prefix appended before the generated id (ignored when `deterministic` is set) + * @returns {ComputedRef<string>} A stable, SSR-safe id that matches between server and client + * + * @example + * const id = useId(); + * // => 'robonen-v-1' + * + * @example + * const id = useId(() => props.id, 'dialog'); + * // => props.id ?? 'dialog-v-1' + * + * @since 0.0.14 + */ +export function useId( + deterministic?: MaybeRefOrGetter<string | undefined>, + prefix = 'robonen', +): ComputedRef<string> { + const generated = vueUseId(); + + return computed(() => toValue(deterministic) || `${prefix}-${generated}`); +} diff --git a/vue/toolkit/src/composables/state/useInjectionStore/index.test.ts b/vue/toolkit/src/composables/state/useInjectionStore/index.test.ts index 86d96f8..e1c38f2 100644 --- a/vue/toolkit/src/composables/state/useInjectionStore/index.test.ts +++ b/vue/toolkit/src/composables/state/useInjectionStore/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { defineComponent, ref } from 'vue'; import { useInjectionStore } from '.'; import { mount } from '@vue/test-utils'; @@ -45,7 +45,7 @@ describe('useInjectionState', () => { const { Child } = testFactory( useInjectionStore(() => ref('without provider'), { defaultValue: ref('default'), - injectionKey: 'testKey', + injectionName: 'testKey', }), ); @@ -74,7 +74,7 @@ describe('useInjectionState', () => { it('works with custom injection key', () => { const { Parent } = testFactory( useInjectionStore(() => ref('custom key'), { - injectionKey: Symbol('customKey'), + injectionName: 'customKey', }), ); diff --git a/vue/toolkit/src/composables/state/useStepper/index.test.ts b/vue/toolkit/src/composables/state/useStepper/index.test.ts new file mode 100644 index 0000000..e27fcba --- /dev/null +++ b/vue/toolkit/src/composables/state/useStepper/index.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from 'vitest'; +import { effectScope, nextTick, ref } from 'vue'; +import { useStepper } from '.'; + +describe(useStepper, () => { + describe('array of steps', () => { + it('initializes on the first step by default', () => { + const { index, current, stepNames } = useStepper(['first', 'second', 'last']); + expect(index.value).toBe(0); + expect(current.value).toBe('first'); + expect(stepNames.value).toEqual(['first', 'second', 'last']); + }); + + it('initializes on the provided initial step', () => { + const { index, current } = useStepper(['first', 'second', 'last'], 'second'); + expect(index.value).toBe(1); + expect(current.value).toBe('second'); + }); + + it('exposes the original steps ref', () => { + const { steps } = useStepper(['first', 'second']); + expect(steps.value).toEqual(['first', 'second']); + }); + + it('computes next and previous step names', () => { + const { next, previous, goToNext } = useStepper(['first', 'second', 'last']); + expect(previous.value).toBeUndefined(); + expect(next.value).toBe('second'); + goToNext(); + expect(previous.value).toBe('first'); + expect(next.value).toBe('last'); + }); + + it('next/previous are undefined at the boundaries', () => { + const { next, previous, goTo } = useStepper(['first', 'second', 'last']); + expect(previous.value).toBeUndefined(); + goTo('last'); + expect(next.value).toBeUndefined(); + }); + + it('tracks isFirst and isLast', () => { + const { isFirst, isLast, goToNext } = useStepper(['first', 'second', 'last']); + expect(isFirst.value).toBeTruthy(); + expect(isLast.value).toBeFalsy(); + goToNext(); + expect(isFirst.value).toBeFalsy(); + expect(isLast.value).toBeFalsy(); + goToNext(); + expect(isFirst.value).toBeFalsy(); + expect(isLast.value).toBeTruthy(); + }); + }); + + describe('navigation', () => { + it('goToNext advances the index but stops at the last step', () => { + const { index, goToNext } = useStepper(['first', 'second', 'last']); + goToNext(); + expect(index.value).toBe(1); + goToNext(); + expect(index.value).toBe(2); + goToNext(); + expect(index.value).toBe(2); + }); + + it('goToPrevious decrements the index but stops at the first step', () => { + const { index, goToPrevious } = useStepper(['first', 'second', 'last'], 'last'); + goToPrevious(); + expect(index.value).toBe(1); + goToPrevious(); + expect(index.value).toBe(0); + goToPrevious(); + expect(index.value).toBe(0); + }); + + it('goTo jumps to a named step', () => { + const { index, current, goTo } = useStepper(['first', 'second', 'last']); + goTo('last'); + expect(index.value).toBe(2); + expect(current.value).toBe('last'); + }); + + it('goTo ignores unknown steps', () => { + const { index, goTo } = useStepper(['first', 'second', 'last']); + goTo('missing' as any); + expect(index.value).toBe(0); + }); + + it('goBackTo only navigates backwards', () => { + const { index, goBackTo, goTo } = useStepper(['first', 'second', 'last']); + goTo('last'); + goBackTo('first'); + expect(index.value).toBe(0); + // already before 'last', so this is a no-op + goBackTo('last'); + expect(index.value).toBe(0); + }); + }); + + describe('predicates', () => { + it('isCurrent / isNext / isPrevious reflect the current index', () => { + const { isCurrent, isNext, isPrevious, goToNext } = useStepper(['first', 'second', 'last']); + expect(isCurrent('first')).toBeTruthy(); + expect(isNext('second')).toBeTruthy(); + expect(isPrevious('second')).toBeFalsy(); + goToNext(); + expect(isCurrent('second')).toBeTruthy(); + expect(isPrevious('first')).toBeTruthy(); + expect(isNext('last')).toBeTruthy(); + }); + + it('isBefore / isAfter compare against the current position', () => { + const { isBefore, isAfter, goTo } = useStepper(['first', 'second', 'last']); + goTo('second'); + expect(isBefore('last')).toBeTruthy(); + expect(isBefore('first')).toBeFalsy(); + expect(isAfter('first')).toBeTruthy(); + expect(isAfter('last')).toBeFalsy(); + }); + }); + + describe('accessors', () => { + it('at returns the step at an index', () => { + const { at } = useStepper(['first', 'second', 'last']); + expect(at(0)).toBe('first'); + expect(at(2)).toBe('last'); + expect(at(99)).toBeUndefined(); + }); + + it('get returns a step by name and undefined for unknown', () => { + const { get } = useStepper(['first', 'second', 'last']); + expect(get('second')).toBe('second'); + expect(get('missing' as any)).toBeUndefined(); + }); + }); + + describe('record of steps', () => { + const makeSteps = () => ({ + account: { title: 'Account' }, + billing: { title: 'Billing' }, + review: { title: 'Review' }, + }); + + it('derives step names from the record keys', () => { + const { stepNames, current } = useStepper(makeSteps()); + expect(stepNames.value).toEqual(['account', 'billing', 'review']); + expect(current.value).toEqual({ title: 'Account' }); + }); + + it('current resolves to the step value', () => { + const { current, goToNext } = useStepper(makeSteps()); + goToNext(); + expect(current.value).toEqual({ title: 'Billing' }); + }); + + it('at and get resolve record values', () => { + const { at, get } = useStepper(makeSteps()); + expect(at(1)).toEqual({ title: 'Billing' }); + expect(get('review')).toEqual({ title: 'Review' }); + }); + + it('honors the initial step', () => { + const { current, index } = useStepper(makeSteps(), 'review'); + expect(index.value).toBe(2); + expect(current.value).toEqual({ title: 'Review' }); + }); + }); + + describe('reactivity', () => { + it('updates when steps come from a ref', () => { + const steps = ref(['first', 'second']); + const { stepNames, next } = useStepper(steps); + expect(stepNames.value).toEqual(['first', 'second']); + steps.value = ['first', 'second', 'third']; + expect(stepNames.value).toEqual(['first', 'second', 'third']); + expect(next.value).toBe('second'); + }); + + it('current is reactive to index changes', async () => { + const scope = effectScope(); + await scope.run(async () => { + const { current, index } = useStepper(['first', 'second', 'last']); + index.value = 2; + await nextTick(); + expect(current.value).toBe('last'); + }); + scope.stop(); + }); + }); +}); diff --git a/vue/toolkit/src/composables/state/useStepper/index.ts b/vue/toolkit/src/composables/state/useStepper/index.ts new file mode 100644 index 0000000..1913de2 --- /dev/null +++ b/vue/toolkit/src/composables/state/useStepper/index.ts @@ -0,0 +1,154 @@ +import { computed, ref } from 'vue'; +import type { ComputedRef, MaybeRef, Ref } from 'vue'; +import { isArray } from '@robonen/stdlib'; + +export interface UseStepperReturn<StepName, Steps, Step> { + /** List of steps. */ + steps: Readonly<Ref<Steps>>; + /** List of step names. */ + stepNames: Readonly<Ref<StepName[]>>; + /** Index of the current step. */ + index: Ref<number>; + /** Current step. */ + current: ComputedRef<Step>; + /** Next step name, or undefined if the current step is the last one. */ + next: ComputedRef<StepName | undefined>; + /** Previous step name, or undefined if the current step is the first one. */ + previous: ComputedRef<StepName | undefined>; + /** Whether the current step is the first one. */ + isFirst: ComputedRef<boolean>; + /** Whether the current step is the last one. */ + isLast: ComputedRef<boolean>; + /** Get the step at the specified index. */ + at: (index: number) => Step | undefined; + /** Get a step by the specified name. */ + get: (step: StepName) => Step | undefined; + /** Go to the specified step. */ + goTo: (step: StepName) => void; + /** Go to the next step. Does nothing if the current step is the last one. */ + goToNext: () => void; + /** Go to the previous step. Does nothing if the current step is the first one. */ + goToPrevious: () => void; + /** Go back to the given step, only if the current step is after it. */ + goBackTo: (step: StepName) => void; + /** Checks whether the given step is the next step. */ + isNext: (step: StepName) => boolean; + /** Checks whether the given step is the previous step. */ + isPrevious: (step: StepName) => boolean; + /** Checks whether the given step is the current step. */ + isCurrent: (step: StepName) => boolean; + /** Checks if the current step is before the given step. */ + isBefore: (step: StepName) => boolean; + /** Checks if the current step is after the given step. */ + isAfter: (step: StepName) => boolean; +} + +/** + * @name useStepper + * @category State + * @description A composable for building wizards/steppers over a list or record of steps + * + * @param {MaybeRef<T[] | Record<string, any>>} steps The list of steps, or a record keyed by step name + * @param {T} [initialStep] The step to start on (defaults to the first step) + * @returns {UseStepperReturn} The stepper state and navigation helpers + * + * @example + * const { current, goToNext, isLast } = useStepper(['first', 'second', 'last']); + * + * @example + * const { current, stepNames, goTo } = useStepper({ + * account: { title: 'Account' }, + * billing: { title: 'Billing' }, + * }); + * + * @since 0.0.15 + */ +export function useStepper<T extends string | number>( + steps: MaybeRef<T[]>, + initialStep?: T, +): UseStepperReturn<T, T[], T>; +export function useStepper<T extends Record<string, any>>( + steps: MaybeRef<T>, + initialStep?: keyof T, +): UseStepperReturn<Exclude<keyof T, symbol>, T, T[keyof T]>; +export function useStepper(steps: any, initialStep?: any): UseStepperReturn<any, any, any> { + const stepsRef = ref<any>(steps); + + const stepNames = computed<any[]>(() => + isArray(stepsRef.value) ? stepsRef.value : Object.keys(stepsRef.value), + ); + + const index = ref(stepNames.value.indexOf(initialStep ?? stepNames.value[0])); + + const at = (at: number): any => { + if (isArray(stepsRef.value)) + return stepsRef.value[at]; + + return stepsRef.value[stepNames.value[at]]; + }; + + const current = computed(() => at(index.value)); + const isFirst = computed(() => index.value === 0); + const isLast = computed(() => index.value === stepNames.value.length - 1); + const next = computed(() => stepNames.value[index.value + 1]); + const previous = computed(() => stepNames.value[index.value - 1]); + + const get = (step: any): any => { + if (!stepNames.value.includes(step)) + return; + + return at(stepNames.value.indexOf(step)); + }; + + const goTo = (step: any): void => { + if (stepNames.value.includes(step)) + index.value = stepNames.value.indexOf(step); + }; + + const goToNext = (): void => { + if (isLast.value) + return; + + index.value++; + }; + + const goToPrevious = (): void => { + if (isFirst.value) + return; + + index.value--; + }; + + const isNext = (step: any): boolean => stepNames.value.indexOf(step) === index.value + 1; + const isPrevious = (step: any): boolean => stepNames.value.indexOf(step) === index.value - 1; + const isCurrent = (step: any): boolean => stepNames.value.indexOf(step) === index.value; + const isBefore = (step: any): boolean => index.value < stepNames.value.indexOf(step); + const isAfter = (step: any): boolean => index.value > stepNames.value.indexOf(step); + + const goBackTo = (step: any): void => { + if (isAfter(step)) + goTo(step); + }; + + return { + steps: stepsRef, + stepNames, + index, + current, + next, + previous, + isFirst, + isLast, + at, + get, + goTo, + goToNext, + goToPrevious, + goBackTo, + isNext, + isPrevious, + isCurrent, + isBefore, + isAfter, + }; +} diff --git a/vue/toolkit/src/composables/state/useToggle/index.test.ts b/vue/toolkit/src/composables/state/useToggle/index.test.ts index bd0df23..cd4570b 100644 --- a/vue/toolkit/src/composables/state/useToggle/index.test.ts +++ b/vue/toolkit/src/composables/state/useToggle/index.test.ts @@ -1,4 +1,4 @@ -import { it, expect, describe } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { ref } from 'vue'; import { useToggle } from '.'; diff --git a/vue/toolkit/src/composables/state/useToggle/index.ts b/vue/toolkit/src/composables/state/useToggle/index.ts index d34f9f9..b515a35 100644 --- a/vue/toolkit/src/composables/state/useToggle/index.ts +++ b/vue/toolkit/src/composables/state/useToggle/index.ts @@ -1,5 +1,5 @@ import { ref, toValue } from 'vue'; -import type { MaybeRefOrGetter, MaybeRef, Ref } from 'vue'; +import type { MaybeRef, MaybeRefOrGetter, Ref } from 'vue'; export interface UseToggleOptions<Truthy, Falsy> { truthyValue?: MaybeRefOrGetter<Truthy>; diff --git a/vue/toolkit/src/composables/storage/useLocalStorage/index.test.ts b/vue/toolkit/src/composables/storage/useLocalStorage/index.test.ts index a889054..151fdbb 100644 --- a/vue/toolkit/src/composables/storage/useLocalStorage/index.test.ts +++ b/vue/toolkit/src/composables/storage/useLocalStorage/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { nextTick } from 'vue'; import { useLocalStorage } from '.'; diff --git a/vue/toolkit/src/composables/storage/useSessionStorage/index.test.ts b/vue/toolkit/src/composables/storage/useSessionStorage/index.test.ts index 0872c8a..8722b1d 100644 --- a/vue/toolkit/src/composables/storage/useSessionStorage/index.test.ts +++ b/vue/toolkit/src/composables/storage/useSessionStorage/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { nextTick } from 'vue'; import { useSessionStorage } from '.'; diff --git a/vue/toolkit/src/composables/storage/useStorage/index.test.ts b/vue/toolkit/src/composables/storage/useStorage/index.test.ts index aa9241d..5d996bc 100644 --- a/vue/toolkit/src/composables/storage/useStorage/index.test.ts +++ b/vue/toolkit/src/composables/storage/useStorage/index.test.ts @@ -1,7 +1,7 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { nextTick, ref } from 'vue'; -import { useStorage, StorageSerializers, customStorageEventName } from '.'; -import type { StorageLike, StorageEventLike } from '.'; +import { StorageSerializers, customStorageEventName, useStorage } from '.'; +import type { StorageEventLike, StorageLike } from '.'; function createMockStorage(): StorageLike & { store: Map<string, string> } { const store = new Map<string, string>(); @@ -325,7 +325,7 @@ describe(useStorage, () => { const storage = createMockStorage(); const state = useStorage<string>('sync-key', 'initial', storage, { listenToStorageChanges: true, - window: globalThis, + window: globalThis as unknown as Window, }); expect(state.value).toBe('initial'); @@ -347,7 +347,7 @@ describe(useStorage, () => { const storage = createMockStorage(); const state = useStorage<string>('my-key', 'initial', storage, { listenToStorageChanges: true, - window: globalThis, + window: globalThis as unknown as Window, }); const detail: StorageEventLike = { @@ -369,7 +369,7 @@ describe(useStorage, () => { const state = useStorage<string>('clear-key', 'default', storage, { listenToStorageChanges: true, - window: globalThis, + window: globalThis as unknown as Window, }); expect(state.value).toBe('stored'); @@ -392,7 +392,7 @@ describe(useStorage, () => { const storage = createMockStorage(); const state = useStorage<string>('no-listen', 'initial', storage, { listenToStorageChanges: false, - window: globalThis, + window: globalThis as unknown as Window, }); const detail: StorageEventLike = { @@ -414,7 +414,7 @@ describe(useStorage, () => { const storage = createMockStorage(); const state = useStorage<string>('custom-backend', 'initial', storage, { listenToStorageChanges: true, - window: globalThis, + window: globalThis as unknown as Window, }); const detail: StorageEventLike = { diff --git a/vue/toolkit/src/composables/storage/useStorage/index.ts b/vue/toolkit/src/composables/storage/useStorage/index.ts index 2bf8aba..fd2e8e1 100644 --- a/vue/toolkit/src/composables/storage/useStorage/index.ts +++ b/vue/toolkit/src/composables/storage/useStorage/index.ts @@ -1,6 +1,6 @@ import { nextTick, ref, shallowRef, toValue, watch } from 'vue'; import type { MaybeRefOrGetter } from 'vue'; -import { isBoolean, isNumber, isString, isObject, isMap, isSet, isDate } from '@robonen/stdlib'; +import { isBoolean, isDate, isFunction, isMap, isNumber, isObject, isSet, isString } from '@robonen/stdlib'; import type { ConfigurableFlush, ConfigurableWindow, RemovableRef } from '@/types'; import { defaultWindow } from '@/types'; import type { ConfigurableEventFilter, EventFilter } from '@/utils/filters'; @@ -206,7 +206,7 @@ export function useStorage<T>( if (!event && mergeDefaults) { const value = serializer.read(rawValue); - return typeof mergeDefaults === 'function' + return isFunction(mergeDefaults) ? mergeDefaults(value, defaults) : shallowMerge(value, defaults); } diff --git a/vue/toolkit/src/composables/storage/useStorageAsync/index.test.ts b/vue/toolkit/src/composables/storage/useStorageAsync/index.test.ts index 3b77126..ac101e5 100644 --- a/vue/toolkit/src/composables/storage/useStorageAsync/index.test.ts +++ b/vue/toolkit/src/composables/storage/useStorageAsync/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { nextTick, ref } from 'vue'; import { useStorageAsync } from '.'; import type { StorageLikeAsync } from '.'; diff --git a/vue/toolkit/src/composables/storage/useStorageAsync/index.ts b/vue/toolkit/src/composables/storage/useStorageAsync/index.ts index d46758f..79fab2d 100644 --- a/vue/toolkit/src/composables/storage/useStorageAsync/index.ts +++ b/vue/toolkit/src/composables/storage/useStorageAsync/index.ts @@ -1,5 +1,6 @@ -import { computed, ref, shallowRef, watch, toValue } from 'vue'; -import type { Ref, ShallowRef, MaybeRefOrGetter, UnwrapRef } from 'vue'; +import { computed, ref, shallowRef, toValue, watch } from 'vue'; +import type { MaybeRefOrGetter, Ref, ShallowRef, UnwrapRef } from 'vue'; +import { isFunction } from '@robonen/stdlib'; import type { ConfigurableFlush, ConfigurableWindow } from '@/types'; import { defaultWindow } from '@/types'; import type { ConfigurableEventFilter, EventFilter } from '@/utils/filters'; @@ -154,7 +155,7 @@ export function useStorageAsync<T, Shallow extends boolean = true>( if (!event && mergeDefaults) { const value: T = await serializer.read(rawValue) as T; - return typeof mergeDefaults === 'function' + return isFunction(mergeDefaults) ? mergeDefaults(value, defaults) : shallowMerge(value, defaults); } diff --git a/vue/toolkit/src/composables/utilities/index.ts b/vue/toolkit/src/composables/utilities/index.ts index 901130d..5821dd2 100644 --- a/vue/toolkit/src/composables/utilities/index.ts +++ b/vue/toolkit/src/composables/utilities/index.ts @@ -1 +1,6 @@ +export * from './useDebounceFn'; +export * from './useInterval'; export * from './useOffsetPagination'; +export * from './useThrottleFn'; +export * from './useTimeoutFn'; +export * from './useTimestamp'; diff --git a/vue/toolkit/src/composables/utilities/useDebounceFn/index.test.ts b/vue/toolkit/src/composables/utilities/useDebounceFn/index.test.ts new file mode 100644 index 0000000..faa502b --- /dev/null +++ b/vue/toolkit/src/composables/utilities/useDebounceFn/index.test.ts @@ -0,0 +1,261 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, nextTick } from 'vue'; +import { useDebounceFn } from '.'; + +describe(useDebounceFn, () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('delays invocation until ms elapsed', () => { + const fn = vi.fn(); + const debounced = useDebounceFn(fn, 100); + + debounced(); + expect(fn).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(100); + expect(fn).toHaveBeenCalledOnce(); + }); + + it('coalesces rapid calls into one with the latest args', () => { + const fn = vi.fn(); + const debounced = useDebounceFn(fn, 100); + + debounced('a'); + debounced('b'); + debounced('c'); + + vi.advanceTimersByTime(100); + expect(fn).toHaveBeenCalledOnce(); + expect(fn).toHaveBeenCalledWith('c'); + }); + + it('resolves the promise with the function result', async () => { + const debounced = useDebounceFn((x: number) => x * 2, 100); + + const promise = debounced(21); + vi.advanceTimersByTime(100); + + await expect(promise).resolves.toBe(42); + }); + + it('rejects the promise when the function throws', async () => { + const debounced = useDebounceFn(() => { + throw new Error('boom'); + }, 100); + + const promise = debounced(); + vi.advanceTimersByTime(100); + + await expect(promise).rejects.toThrow('boom'); + }); + + it('preserves the `this` context', () => { + const ctx = { value: 7, fn: vi.fn() }; + const obj = { + value: 7, + debounced: useDebounceFn(function (this: typeof ctx) { + ctx.fn(this.value); + }, 100), + } as unknown as typeof ctx & { debounced: () => Promise<void> }; + + obj.debounced(); + vi.advanceTimersByTime(100); + expect(ctx.fn).toHaveBeenCalledWith(7); + }); + + it('runs synchronously when ms <= 0', () => { + const fn = vi.fn(); + const debounced = useDebounceFn(fn, 0); + + debounced(); + expect(fn).toHaveBeenCalledOnce(); + }); + + it('accepts a reactive/getter delay', () => { + const fn = vi.fn(); + let ms = 100; + const debounced = useDebounceFn(fn, () => ms); + + debounced(); + vi.advanceTimersByTime(100); + expect(fn).toHaveBeenCalledTimes(1); + + ms = 300; + debounced(); + vi.advanceTimersByTime(100); + expect(fn).toHaveBeenCalledTimes(1); // not yet — delay grew to 300 + vi.advanceTimersByTime(200); + expect(fn).toHaveBeenCalledTimes(2); + }); + + describe('isPending', () => { + it('reflects the pending state', () => { + const debounced = useDebounceFn(vi.fn(), 100); + expect(debounced.isPending.value).toBeFalsy(); + + debounced(); + expect(debounced.isPending.value).toBeTruthy(); + + vi.advanceTimersByTime(100); + expect(debounced.isPending.value).toBeFalsy(); + }); + + it('is false after ms <= 0 synchronous calls', () => { + const debounced = useDebounceFn(vi.fn(), 0); + debounced(); + expect(debounced.isPending.value).toBeFalsy(); + }); + }); + + describe('cancel', () => { + it('cancels a pending invocation', () => { + const fn = vi.fn(); + const debounced = useDebounceFn(fn, 100); + + debounced(); + debounced.cancel(); + expect(debounced.isPending.value).toBeFalsy(); + + vi.advanceTimersByTime(100); + expect(fn).not.toHaveBeenCalled(); + }); + + it('resolves the pending promise with undefined by default', async () => { + const debounced = useDebounceFn((x: number) => x, 100); + + const promise = debounced(5); + debounced.cancel(); + + await expect(promise).resolves.toBeUndefined(); + }); + + it('rejects the pending promise when rejectOnCancel is set', async () => { + const debounced = useDebounceFn((x: number) => x, 100, { rejectOnCancel: true }); + + const promise = debounced(5); + const assertion = expect(promise).rejects.toBeUndefined(); + debounced.cancel(); + + await assertion; + }); + + it('is a no-op when nothing is pending', () => { + const debounced = useDebounceFn(vi.fn(), 100); + expect(() => debounced.cancel()).not.toThrow(); + }); + }); + + describe('flush', () => { + it('invokes the pending call immediately', () => { + const fn = vi.fn(); + const debounced = useDebounceFn(fn, 100); + + debounced('x'); + debounced.flush(); + + expect(fn).toHaveBeenCalledOnce(); + expect(fn).toHaveBeenCalledWith('x'); + expect(debounced.isPending.value).toBeFalsy(); + }); + + it('resolves the pending promise with the result', async () => { + const debounced = useDebounceFn((x: number) => x * 3, 100); + + const promise = debounced(4); + debounced.flush(); + + await expect(promise).resolves.toBe(12); + }); + + it('does not invoke twice when the timer later fires', () => { + const fn = vi.fn(); + const debounced = useDebounceFn(fn, 100); + + debounced(); + debounced.flush(); + vi.advanceTimersByTime(100); + + expect(fn).toHaveBeenCalledOnce(); + }); + + it('is a no-op when nothing is pending', () => { + const fn = vi.fn(); + const debounced = useDebounceFn(fn, 100); + debounced.flush(); + expect(fn).not.toHaveBeenCalled(); + }); + }); + + describe('maxWait', () => { + it('forces invocation after maxWait under sustained calls', () => { + const fn = vi.fn(); + const debounced = useDebounceFn(fn, 100, { maxWait: 250 }); + + // Keep resetting the 100ms timer every 80ms so it never fires on its own. + debounced(); + vi.advanceTimersByTime(80); + debounced(); + vi.advanceTimersByTime(80); + debounced(); + vi.advanceTimersByTime(80); + expect(fn).not.toHaveBeenCalled(); // 240ms elapsed, under maxWait + + vi.advanceTimersByTime(10); // 250ms total — maxWait fires + expect(fn).toHaveBeenCalledOnce(); + }); + + it('resets the maxWait window after firing', () => { + const fn = vi.fn(); + const debounced = useDebounceFn(fn, 100, { maxWait: 250 }); + + debounced(); + vi.advanceTimersByTime(250); + expect(fn).toHaveBeenCalledTimes(1); + + debounced(); + vi.advanceTimersByTime(100); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('runs synchronously when maxWait <= 0', () => { + const fn = vi.fn(); + const debounced = useDebounceFn(fn, 100, { maxWait: 0 }); + debounced(); + expect(fn).toHaveBeenCalledOnce(); + }); + }); + + describe('scope disposal', () => { + it('cancels pending timers when the owning scope is disposed', () => { + const fn = vi.fn(); + const scope = effectScope(); + let debounced!: ReturnType<typeof useDebounceFn>; + + scope.run(() => { + debounced = useDebounceFn(fn, 100); + }); + + debounced(); + expect(debounced.isPending.value).toBeTruthy(); + + scope.stop(); + vi.advanceTimersByTime(100); + expect(fn).not.toHaveBeenCalled(); + expect(debounced.isPending.value).toBeFalsy(); + }); + }); + + it('settles superseded promises with undefined (default)', async () => { + const debounced = useDebounceFn((x: string) => x, 100); + + const first = debounced('a'); + const second = debounced('b'); + + vi.advanceTimersByTime(100); + await nextTick(); + + await expect(first).resolves.toBeUndefined(); + await expect(second).resolves.toBe('b'); + }); +}); diff --git a/vue/toolkit/src/composables/utilities/useDebounceFn/index.ts b/vue/toolkit/src/composables/utilities/useDebounceFn/index.ts new file mode 100644 index 0000000..118b3c7 --- /dev/null +++ b/vue/toolkit/src/composables/utilities/useDebounceFn/index.ts @@ -0,0 +1,156 @@ +import { shallowReadonly, shallowRef, toValue } from 'vue'; +import type { MaybeRefOrGetter, Ref } from 'vue'; +import { debounce } from '@robonen/stdlib'; +import type { AnyFunction } from '@robonen/stdlib'; +import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose'; + +export interface UseDebounceFnOptions { + /** + * The maximum time `fn` is allowed to be delayed before it is forcibly + * invoked, even if calls keep arriving. Guarantees progress under sustained + * input. When omitted there is no upper bound. + * + * @default undefined + */ + maxWait?: number; + + /** + * Reject the pending promise (instead of silently resolving it) when a call + * is cancelled — either explicitly via `cancel()` or implicitly when a newer + * call supersedes it. + * + * @default false + */ + rejectOnCancel?: boolean; +} + +export interface UseDebounceFnReturn<T extends AnyFunction> { + /** + * Invoke the debounced function. Returns a promise that resolves with the + * wrapped function's result once the trailing edge fires. Superseded calls + * resolve with `undefined` (or reject when `rejectOnCancel` is set). + */ + (this: ThisParameterType<T>, ...args: Parameters<T>): Promise<Awaited<ReturnType<T>>>; + + /** + * Cancel the pending invocation, if any. + */ + cancel: () => void; + + /** + * Immediately invoke the pending call (if any) ahead of its timer. + */ + flush: () => void; + + /** + * Whether a debounced invocation is currently scheduled. + */ + readonly isPending: Readonly<Ref<boolean>>; +} + +/** + * @name useDebounceFn + * @category Utilities + * @description Debounce execution of a function — a thin reactive wrapper around + * `@robonen/stdlib`'s `debounce`. Postpones invocation until `ms` have elapsed + * since the last call and resolves with the wrapped function's result. Supports + * a reactive delay, a `maxWait` ceiling, `rejectOnCancel`, and exposes `cancel`, + * `flush`, and `isPending`. Pending timers are cleared on scope dispose. + * + * @param {T} fn The function to debounce + * @param {MaybeRefOrGetter<number>} [ms=200] Delay in milliseconds (can be reactive) + * @param {UseDebounceFnOptions} [options] Debounce options (`maxWait`, `rejectOnCancel`) + * @returns {UseDebounceFnReturn<T>} The debounced function with `cancel`, `flush`, and `isPending` + * + * @example + * const search = useDebounceFn(() => fetchResults(query.value), 300); + * watch(query, search); + * + * @example + * const save = useDebounceFn(persist, 300, { maxWait: 1000 }); + * save.cancel(); + * save.flush(); + * if (save.isPending.value) {} + * + * @since 0.0.15 + */ +export function useDebounceFn<T extends AnyFunction>( + fn: T, + ms: MaybeRefOrGetter<number> = 200, + options: UseDebounceFnOptions = {}, +): UseDebounceFnReturn<T> { + const { maxWait, rejectOnCancel = false } = options; + + const isPending = shallowRef(false); + + // The latest unresolved promise settler; superseded ones are settled early. + let settler: { resolve: (value?: unknown) => void; reject: (reason?: unknown) => void } | undefined; + + function settleCancelled() { + const pending = settler; + settler = undefined; + + if (!pending) + return; + + if (rejectOnCancel) + pending.reject(); + else + pending.resolve(undefined); + } + + // The function stdlib debounces: runs `fn` and settles the latest promise. + function run(this: ThisParameterType<T>, ...args: Parameters<T>) { + isPending.value = false; + const pending = settler; + settler = undefined; + + try { + const result = fn.apply(this, args) as Awaited<ReturnType<T>>; + pending?.resolve(result); + return result; + } + catch (error) { + pending?.reject(error); + return undefined; + } + } + + const debounced = debounce(run as AnyFunction, () => toValue(ms), { maxWait, leading: false, trailing: true }); + + const wrapper = function (this: ThisParameterType<T>, ...args: Parameters<T>) { + return new Promise<Awaited<ReturnType<T>>>((resolve, reject) => { + // A new call supersedes the previous pending promise. + settleCancelled(); + settler = { resolve: resolve as (value?: unknown) => void, reject }; + + const duration = toValue(ms); + + // Fast path: non-positive delay (or maxWait) runs synchronously. + if (duration <= 0 || (maxWait !== undefined && maxWait <= 0)) { + debounced.cancel(); + isPending.value = false; + run.apply(this, args); + return; + } + + isPending.value = true; + debounced.apply(this, args); + }); + } as UseDebounceFnReturn<T>; + + wrapper.cancel = () => { + debounced.cancel(); + isPending.value = false; + settleCancelled(); + }; + + wrapper.flush = () => { + // stdlib flush synchronously runs `run`, which settles + clears isPending. + debounced.flush(); + }; + + tryOnScopeDispose(wrapper.cancel); + + return Object.assign(wrapper, { isPending: shallowReadonly(isPending) }) as UseDebounceFnReturn<T>; +} diff --git a/vue/toolkit/src/composables/utilities/useInterval/index.test.ts b/vue/toolkit/src/composables/utilities/useInterval/index.test.ts new file mode 100644 index 0000000..0c483c9 --- /dev/null +++ b/vue/toolkit/src/composables/utilities/useInterval/index.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { isReadonly } from 'vue'; +import { useInterval } from '.'; + +describe(useInterval, () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('increments the counter on every tick', () => { + const counter = useInterval(100); + + expect(counter.value).toBe(0); + vi.advanceTimersByTime(100); + expect(counter.value).toBe(1); + vi.advanceTimersByTime(200); + expect(counter.value).toBe(3); + }); + + it('returns a read-only counter', () => { + const counter = useInterval(100); + expect(isReadonly(counter)).toBeTruthy(); + }); + + it('does not tick when immediate is false', () => { + const counter = useInterval(100, { immediate: false }); + vi.advanceTimersByTime(300); + expect(counter.value).toBe(0); + }); + + it('exposes controls and reset when controls: true', () => { + const { counter, pause, reset } = useInterval(100, { controls: true }); + + vi.advanceTimersByTime(200); + expect(counter.value).toBe(2); + + pause(); + vi.advanceTimersByTime(200); + expect(counter.value).toBe(2); + + reset(); + expect(counter.value).toBe(0); + }); + + it('exposes a read-only counter in controls mode', () => { + const { counter } = useInterval(100, { controls: true }); + expect(isReadonly(counter)).toBeTruthy(); + }); + + it('exposes isActive reflecting the running state', () => { + const { isActive, pause, resume } = useInterval(100, { controls: true }); + + expect(isActive.value).toBeTruthy(); + + pause(); + expect(isActive.value).toBeFalsy(); + + resume(); + expect(isActive.value).toBeTruthy(); + }); + + it('resumes after a pause and keeps counting from where it left off', () => { + const { counter, pause, resume } = useInterval(100, { controls: true }); + + vi.advanceTimersByTime(100); + expect(counter.value).toBe(1); + + pause(); + vi.advanceTimersByTime(300); + expect(counter.value).toBe(1); + + resume(); + vi.advanceTimersByTime(200); + expect(counter.value).toBe(3); + }); + + it('toggle flips the active state', () => { + const { isActive, toggle } = useInterval(100, { controls: true }); + + expect(isActive.value).toBeTruthy(); + toggle(); + expect(isActive.value).toBeFalsy(); + toggle(); + expect(isActive.value).toBeTruthy(); + }); + + it('invokes the callback with the incremented counter value', () => { + const callback = vi.fn(); + useInterval(100, { callback }); + + vi.advanceTimersByTime(100); + expect(callback).toHaveBeenCalledWith(1); + vi.advanceTimersByTime(100); + expect(callback).toHaveBeenLastCalledWith(2); + }); + + it('reset keeps the interval running', () => { + const { counter, reset } = useInterval(100, { controls: true }); + + vi.advanceTimersByTime(200); + expect(counter.value).toBe(2); + + reset(); + expect(counter.value).toBe(0); + + vi.advanceTimersByTime(100); + expect(counter.value).toBe(1); + }); +}); diff --git a/vue/toolkit/src/composables/utilities/useInterval/index.ts b/vue/toolkit/src/composables/utilities/useInterval/index.ts new file mode 100644 index 0000000..ab72c96 --- /dev/null +++ b/vue/toolkit/src/composables/utilities/useInterval/index.ts @@ -0,0 +1,96 @@ +import { shallowReadonly, shallowRef } from 'vue'; +import type { MaybeRefOrGetter, ShallowRef } from 'vue'; +import type { ResumableActions } from '@/types'; +import { useIntervalFn } from '@/composables/browser/useIntervalFn'; +import type { UseIntervalFnReturn } from '@/composables/browser/useIntervalFn'; + +export interface UseIntervalOptions<Controls extends boolean> { + /** + * Expose pause/resume controls alongside the counter + * + * @default false + */ + controls?: Controls; + + /** + * Start the interval immediately + * + * @default true + */ + immediate?: boolean; + + /** + * Callback invoked on every tick with the current counter value + */ + callback?: (count: number) => void; +} + +export interface UseIntervalControls extends ResumableActions { + /** + * The current counter value (read-only; use `reset` to set it back to 0) + */ + counter: Readonly<ShallowRef<number>>; + + /** + * Whether the interval is currently active + */ + isActive: UseIntervalFnReturn['isActive']; + + /** + * Reset the counter back to 0 + */ + reset: () => void; +} + +export type UseIntervalReturn = Readonly<ShallowRef<number>> | UseIntervalControls; + +/** + * @name useInterval + * @category Utilities + * @description Reactive counter that increments on every interval tick. + * + * @param {MaybeRefOrGetter<number>} [interval=1000] Interval in milliseconds (can be reactive) + * @param {UseIntervalOptions} [options={}] Options + * @returns {Readonly<ShallowRef<number>> | UseIntervalControls} The read-only counter, or controls when `controls: true` + * + * @example + * const counter = useInterval(1000); + * + * @example + * const { counter, isActive, pause, resume, reset } = useInterval(1000, { controls: true }); + * + * @since 0.0.15 + */ +export function useInterval(interval?: MaybeRefOrGetter<number>, options?: UseIntervalOptions<false>): Readonly<ShallowRef<number>>; +export function useInterval(interval: MaybeRefOrGetter<number>, options: UseIntervalOptions<true>): UseIntervalControls; +export function useInterval( + interval: MaybeRefOrGetter<number> = 1000, + options: UseIntervalOptions<boolean> = {}, +): UseIntervalReturn { + const { + controls = false, + immediate = true, + callback, + } = options; + + const counter = shallowRef(0); + const reset = (): void => { + counter.value = 0; + }; + + const update = callback + ? () => callback(++counter.value) + : () => void counter.value++; + + const intervalControls = useIntervalFn(update, interval, { immediate }); + + if (controls) { + return { + counter: shallowReadonly(counter), + reset, + ...intervalControls, + }; + } + + return shallowReadonly(counter); +} diff --git a/vue/toolkit/src/composables/utilities/useOffsetPagination/index.test.ts b/vue/toolkit/src/composables/utilities/useOffsetPagination/index.test.ts index 39a2240..b006b6e 100644 --- a/vue/toolkit/src/composables/utilities/useOffsetPagination/index.test.ts +++ b/vue/toolkit/src/composables/utilities/useOffsetPagination/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { nextTick, ref } from 'vue'; import { useOffsetPagination } from '.'; diff --git a/vue/toolkit/src/composables/utilities/useThrottleFn/index.test.ts b/vue/toolkit/src/composables/utilities/useThrottleFn/index.test.ts new file mode 100644 index 0000000..02574f2 --- /dev/null +++ b/vue/toolkit/src/composables/utilities/useThrottleFn/index.test.ts @@ -0,0 +1,214 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ref } from 'vue'; +import { useThrottleFn } from '.'; + +describe(useThrottleFn, () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('invokes immediately on the leading edge', () => { + const fn = vi.fn(); + const throttled = useThrottleFn(fn, 100); + + throttled(); + expect(fn).toHaveBeenCalledOnce(); + }); + + it('ignores calls within the window by default (no trailing)', () => { + const fn = vi.fn(); + const throttled = useThrottleFn(fn, 100); + + throttled(); + throttled(); + throttled(); + vi.advanceTimersByTime(200); + + expect(fn).toHaveBeenCalledOnce(); + }); + + it('invokes trailing when enabled', () => { + const fn = vi.fn(); + const throttled = useThrottleFn(fn, 100, true); + + throttled('a'); + throttled('b'); + expect(fn).toHaveBeenCalledOnce(); + + vi.advanceTimersByTime(100); + expect(fn).toHaveBeenCalledTimes(2); + expect(fn).toHaveBeenLastCalledWith('b'); + }); + + it('resolves the promise with the function result', async () => { + const throttled = useThrottleFn((x: number) => x + 1, 100); + await expect(throttled(1)).resolves.toBe(2); + }); + + it('skips the leading edge when leading is false', () => { + const fn = vi.fn(); + const throttled = useThrottleFn(fn, 100, true, false); + + // first call only opens the window (no leading edge) + throttled('a'); + expect(fn).not.toHaveBeenCalled(); + + // a call inside the same window schedules the trailing edge + throttled('b'); + expect(fn).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(100); + expect(fn).toHaveBeenCalledOnce(); + expect(fn).toHaveBeenLastCalledWith('b'); + }); + + describe('options object', () => { + it('accepts delay/trailing/leading via an options object', () => { + const fn = vi.fn(); + const throttled = useThrottleFn(fn, { delay: 100, trailing: true }); + + throttled('a'); + throttled('b'); + expect(fn).toHaveBeenCalledOnce(); + + vi.advanceTimersByTime(100); + expect(fn).toHaveBeenCalledTimes(2); + expect(fn).toHaveBeenLastCalledWith('b'); + }); + + it('defaults delay to 200 when omitted', () => { + const fn = vi.fn(); + const throttled = useThrottleFn(fn, {}); + + throttled(); // leading edge + expect(fn).toHaveBeenCalledOnce(); + + vi.advanceTimersByTime(199); + throttled(); // still inside the 200ms window → dropped (trailing off) + expect(fn).toHaveBeenCalledOnce(); + + vi.advanceTimersByTime(1); // 200ms elapsed — window reached + throttled(); // new window opens on the leading edge + expect(fn).toHaveBeenCalledTimes(2); + }); + }); + + describe('reactive delay', () => { + it('reads the current delay on each call', () => { + const fn = vi.fn(); + const delay = ref(100); + const throttled = useThrottleFn(fn, delay); + + throttled(); + expect(fn).toHaveBeenCalledOnce(); + + vi.advanceTimersByTime(101); + throttled(); + expect(fn).toHaveBeenCalledTimes(2); + + delay.value = 1000; + vi.advanceTimersByTime(101); + throttled(); + // window grew to 1000ms, so this call is throttled + expect(fn).toHaveBeenCalledTimes(2); + }); + }); + + describe('zero delay', () => { + it('invokes synchronously on every call when delay <= 0', () => { + const fn = vi.fn(); + const throttled = useThrottleFn(fn, 0); + + throttled(); + throttled(); + throttled(); + expect(fn).toHaveBeenCalledTimes(3); + }); + }); + + describe('cancel', () => { + it('drops a pending trailing invocation', () => { + const fn = vi.fn(); + const throttled = useThrottleFn(fn, 100, true); + + throttled('a'); + throttled('b'); + expect(fn).toHaveBeenCalledOnce(); + + throttled.cancel(); + vi.advanceTimersByTime(100); + expect(fn).toHaveBeenCalledOnce(); + }); + + it('resolves the pending promise with undefined by default', async () => { + const throttled = useThrottleFn((x: number) => x, 100, true); + + throttled(1); + const pending = throttled(2); + throttled.cancel(); + + await expect(pending).resolves.toBeUndefined(); + }); + }); + + describe('rejectOnCancel', () => { + it('rejects a superseded trailing promise', async () => { + const throttled = useThrottleFn((x: number) => x, 100, true, true, true); + + throttled(1); + const superseded = throttled(2); + // next call within the window supersedes the previous trailing promise + const latest = throttled(3); + + await expect(superseded).rejects.toThrow(); + vi.advanceTimersByTime(100); + await expect(latest).resolves.toBe(3); + }); + + it('rejects on explicit cancel', async () => { + const throttled = useThrottleFn((x: number) => x, 100, true, true, true); + + throttled(1); + const pending = throttled(2); + throttled.cancel(); + + await expect(pending).rejects.toThrow(); + }); + }); + + describe('flush', () => { + it('invokes the pending trailing call immediately', async () => { + const fn = vi.fn((x: number) => x); + const throttled = useThrottleFn(fn, 100, true); + + throttled(1); + const pending = throttled(2); + expect(fn).toHaveBeenCalledOnce(); + + throttled.flush(); + expect(fn).toHaveBeenCalledTimes(2); + expect(fn).toHaveBeenLastCalledWith(2); + await expect(pending).resolves.toBe(2); + }); + + it('is a no-op when nothing is pending', () => { + const fn = vi.fn(); + const throttled = useThrottleFn(fn, 100, true); + + expect(() => throttled.flush()).not.toThrow(); + expect(fn).not.toHaveBeenCalled(); + }); + }); + + it('preserves the calling context (this)', () => { + const obj = { + value: 42, + method: vi.fn(function (this: { value: number }) { + return this.value; + }), + }; + const throttled = useThrottleFn(obj.method, 100); + + throttled.call(obj); + expect(obj.method).toHaveReturnedWith(42); + }); +}); diff --git a/vue/toolkit/src/composables/utilities/useThrottleFn/index.ts b/vue/toolkit/src/composables/utilities/useThrottleFn/index.ts new file mode 100644 index 0000000..1da910a --- /dev/null +++ b/vue/toolkit/src/composables/utilities/useThrottleFn/index.ts @@ -0,0 +1,185 @@ +import { isRef, toValue } from 'vue'; +import type { MaybeRefOrGetter } from 'vue'; +import { isFunction, isObject, throttle } from '@robonen/stdlib'; +import type { AnyFunction } from '@robonen/stdlib'; + +export interface UseThrottleFnOptions { + /** + * The window in milliseconds in which `fn` is invoked at most once. + * For event callbacks, values around 100–250 (or higher) are most useful. + * + * @default 200 + */ + delay?: MaybeRefOrGetter<number>; + + /** + * Invoke `fn` again on the trailing edge of the window with the most + * recent arguments. + * + * @default false + */ + trailing?: boolean; + + /** + * Invoke `fn` on the leading edge of the window. + * + * @default true + */ + leading?: boolean; + + /** + * Reject the promise of a trailing call when it is superseded by a newer + * call or cancelled, instead of silently resolving it. + * + * @default false + */ + rejectOnCancel?: boolean; +} + +export type UseThrottleFnReturn<T extends AnyFunction> + = ((this: ThisParameterType<T>, ...args: Parameters<T>) => Promise<Awaited<ReturnType<T>>>) & { + /** + * Cancel a pending trailing invocation. Resolves (or rejects, when + * `rejectOnCancel` is set) the pending promise without calling `fn`. + */ + cancel: () => void; + + /** + * Immediately invoke any pending trailing call, resolving its promise. + */ + flush: () => void; + }; + +function normalizeOptions( + ms: MaybeRefOrGetter<number> | UseThrottleFnOptions, + trailing: boolean, + leading: boolean, + rejectOnCancel: boolean, +): Required<UseThrottleFnOptions> { + // Distinguish an options object from a reactive delay (ref/getter/number). + if (isObject(ms) && !isRef(ms) && !isFunction(ms)) { + const options = ms as UseThrottleFnOptions; + + return { + delay: options.delay ?? 200, + trailing: options.trailing ?? false, + leading: options.leading ?? true, + rejectOnCancel: options.rejectOnCancel ?? false, + }; + } + + return { delay: ms, trailing, leading, rejectOnCancel }; +} + +/** + * @name useThrottleFn + * @category Utilities + * @description Throttle execution of a function — a thin reactive wrapper around + * `@robonen/stdlib`'s `throttle`. Invokes `fn` at most once per `delay` window + * and resolves with the wrapped function's result. Especially useful for + * rate-limiting handlers on high-frequency events like `scroll` and `resize`. + * + * Accepts either positional arguments or a single options object, and exposes + * `cancel`/`flush` controls on the returned function. + * + * @param {T} fn The function to throttle + * @param {MaybeRefOrGetter<number> | UseThrottleFnOptions} [ms=200] Window in milliseconds (can be reactive) or an options object + * @param {boolean} [trailing=false] Invoke on the trailing edge of the window + * @param {boolean} [leading=true] Invoke on the leading edge of the window + * @param {boolean} [rejectOnCancel=false] Reject a superseded/cancelled trailing promise instead of resolving it + * @returns {UseThrottleFnReturn<T>} The throttled function with `cancel` and `flush` + * + * @example + * const onScroll = useThrottleFn(() => updatePosition(), 100); + * useEventListener('scroll', onScroll); + * + * @example + * const save = useThrottleFn(persist, { delay: 1000, trailing: true }); + * save.cancel(); + * + * @since 0.0.15 + */ +export function useThrottleFn<T extends AnyFunction>( + fn: T, + options: UseThrottleFnOptions, +): UseThrottleFnReturn<T>; +export function useThrottleFn<T extends AnyFunction>( + fn: T, + ms?: MaybeRefOrGetter<number>, + trailing?: boolean, + leading?: boolean, + rejectOnCancel?: boolean, +): UseThrottleFnReturn<T>; +export function useThrottleFn<T extends AnyFunction>( + fn: T, + ms: MaybeRefOrGetter<number> | UseThrottleFnOptions = 200, + trailing = false, + leading = true, + rejectOnCancel = false, +): UseThrottleFnReturn<T> { + const { delay, trailing: useTrailing, leading: useLeading, rejectOnCancel: useReject } + = normalizeOptions(ms, trailing, leading, rejectOnCancel); + + // The latest unsettled promise; superseded/dropped ones are settled early. + let settler: { resolve: (value?: unknown) => void; reject: (reason?: unknown) => void } | undefined; + let invokedSync = false; + + function settleCancelled() { + const pending = settler; + settler = undefined; + + if (!pending) + return; + + if (useReject) + pending.reject(new Error('throttled call cancelled')); + else + pending.resolve(undefined); + } + + // The function stdlib throttles: runs `fn` and settles the latest promise. + function run(this: ThisParameterType<T>, ...args: Parameters<T>) { + invokedSync = true; + const pending = settler; + settler = undefined; + + try { + const result = fn.apply(this, args) as Awaited<ReturnType<T>>; + pending?.resolve(result); + return result; + } + catch (error) { + pending?.reject(error); + return undefined; + } + } + + const throttled = throttle(run as AnyFunction, () => toValue(delay), { leading: useLeading, trailing: useTrailing }); + + const wrapper = function (this: ThisParameterType<T>, ...args: Parameters<T>) { + return new Promise<Awaited<ReturnType<T>>>((resolve, reject) => { + // A new call supersedes the previous pending (trailing) promise. + settleCancelled(); + settler = { resolve: resolve as (value?: unknown) => void, reject }; + invokedSync = false; + + throttled.apply(this, args); + + // If this call neither fired synchronously (leading) nor scheduled a + // trailing edge, it was dropped — settle its promise now to avoid a leak. + if (!invokedSync && !throttled.pending()) + settleCancelled(); + }); + } as UseThrottleFnReturn<T>; + + wrapper.cancel = () => { + throttled.cancel(); + settleCancelled(); + }; + + wrapper.flush = () => { + throttled.flush(); + }; + + return wrapper; +} diff --git a/vue/toolkit/src/composables/utilities/useTimeoutFn/index.test.ts b/vue/toolkit/src/composables/utilities/useTimeoutFn/index.test.ts new file mode 100644 index 0000000..9d8b71e --- /dev/null +++ b/vue/toolkit/src/composables/utilities/useTimeoutFn/index.test.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, ref } from 'vue'; +import { useTimeoutFn } from '.'; + +describe(useTimeoutFn, () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('calls the callback after the interval', () => { + const cb = vi.fn(); + const { isPending } = useTimeoutFn(cb, 100); + + expect(isPending.value).toBeTruthy(); + expect(cb).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(100); + expect(cb).toHaveBeenCalledOnce(); + expect(isPending.value).toBeFalsy(); + }); + + it('does not start when immediate is false', () => { + const cb = vi.fn(); + const { isPending } = useTimeoutFn(cb, 100, { immediate: false }); + + expect(isPending.value).toBeFalsy(); + vi.advanceTimersByTime(100); + expect(cb).not.toHaveBeenCalled(); + }); + + it('start forwards arguments to the callback', () => { + const cb = vi.fn(); + const { start } = useTimeoutFn(cb, 100, { immediate: false }); + + start('x', 1); + vi.advanceTimersByTime(100); + expect(cb).toHaveBeenCalledWith('x', 1); + }); + + it('restarting before the timeout fires only invokes the callback once with the latest args', () => { + const cb = vi.fn(); + const { start } = useTimeoutFn(cb, 100, { immediate: false }); + + start('first'); + vi.advanceTimersByTime(50); + start('second'); + vi.advanceTimersByTime(50); + expect(cb).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(50); + expect(cb).toHaveBeenCalledOnce(); + expect(cb).toHaveBeenCalledWith('second'); + }); + + it('reads the interval reactively each time start runs', () => { + const cb = vi.fn(); + const delay = ref(100); + const { start } = useTimeoutFn(cb, delay, { immediate: false }); + + start(); + vi.advanceTimersByTime(100); + expect(cb).toHaveBeenCalledOnce(); + + delay.value = 500; + start(); + vi.advanceTimersByTime(100); + expect(cb).toHaveBeenCalledOnce(); + vi.advanceTimersByTime(400); + expect(cb).toHaveBeenCalledTimes(2); + }); + + describe('immediateCallback', () => { + it('invokes the callback synchronously on start and again after the delay', () => { + const cb = vi.fn(); + const { start } = useTimeoutFn(cb, 100, { + immediate: false, + immediateCallback: true, + }); + + start('a'); + expect(cb).toHaveBeenCalledOnce(); + expect(cb).toHaveBeenCalledWith('a'); + + vi.advanceTimersByTime(100); + expect(cb).toHaveBeenCalledTimes(2); + expect(cb).toHaveBeenLastCalledWith('a'); + }); + + it('fires synchronously during immediate auto-start', () => { + const cb = vi.fn(); + useTimeoutFn(cb, 100, { immediateCallback: true }); + + expect(cb).toHaveBeenCalledOnce(); + vi.advanceTimersByTime(100); + expect(cb).toHaveBeenCalledTimes(2); + }); + }); + + it('stop cancels a pending timeout', () => { + const cb = vi.fn(); + const { stop, isPending } = useTimeoutFn(cb, 100); + + stop(); + expect(isPending.value).toBeFalsy(); + vi.advanceTimersByTime(100); + expect(cb).not.toHaveBeenCalled(); + }); + + it('cleans up on scope dispose', () => { + const cb = vi.fn(); + const scope = effectScope(); + scope.run(() => useTimeoutFn(cb, 100)); + + scope.stop(); + vi.advanceTimersByTime(100); + expect(cb).not.toHaveBeenCalled(); + }); +}); diff --git a/vue/toolkit/src/composables/utilities/useTimeoutFn/index.ts b/vue/toolkit/src/composables/utilities/useTimeoutFn/index.ts new file mode 100644 index 0000000..22af269 --- /dev/null +++ b/vue/toolkit/src/composables/utilities/useTimeoutFn/index.ts @@ -0,0 +1,117 @@ +import { readonly, ref, toValue } from 'vue'; +import type { MaybeRefOrGetter, Ref } from 'vue'; +import type { AnyFunction } from '@robonen/stdlib'; +import { isClient } from '@robonen/platform/multi'; +import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose'; + +export interface UseTimeoutFnOptions { + /** + * Start the timeout immediately when the composable is created + * + * @default true + */ + immediate?: boolean; + + /** + * Invoke the callback synchronously the moment `start` is called, + * in addition to the scheduled invocation after the delay elapses + * + * @default false + */ + immediateCallback?: boolean; +} + +export interface UseTimeoutFnReturn<Args extends any[]> { + /** + * Whether the timeout is currently pending + */ + isPending: Readonly<Ref<boolean>>; + + /** + * Start (or restart) the timeout + */ + start: (...args: Args) => void; + + /** + * Cancel the pending timeout + */ + stop: () => void; +} + +/** + * @name useTimeoutFn + * @category Utilities + * @description Call a function after a given delay, with manual `start`/`stop` + * control and a reactive `isPending` flag. SSR-safe and cleans up on scope dispose. + * + * @param {T} cb The function to call after the timeout + * @param {MaybeRefOrGetter<number>} interval Delay in milliseconds (resolved each time `start` runs, can be reactive) + * @param {UseTimeoutFnOptions} [options={}] Options + * @returns {UseTimeoutFnReturn} Timeout controls + * + * @example + * const { isPending, start, stop } = useTimeoutFn(() => { + * console.log('fired'); + * }, 1000); + * + * @example + * // Fire once now and again after the delay + * useTimeoutFn(refresh, 5000, { immediateCallback: true }); + * + * @since 0.0.15 + */ +export function useTimeoutFn<T extends AnyFunction>( + cb: T, + interval: MaybeRefOrGetter<number>, + options: UseTimeoutFnOptions = {}, +): UseTimeoutFnReturn<Parameters<T>> { + const { + immediate = true, + immediateCallback = false, + } = options; + + const isPending = ref(false); + + let timer: ReturnType<typeof setTimeout> | null = null; + + function clear() { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + } + + function stop() { + isPending.value = false; + clear(); + } + + function start(...args: Parameters<T>) { + if (immediateCallback) + cb(...args); + + clear(); + isPending.value = true; + + timer = setTimeout(() => { + isPending.value = false; + timer = null; + cb(...args); + }, toValue(interval)); + } + + if (immediate) { + isPending.value = true; + + if (isClient) + start(...([] as unknown as Parameters<T>)); + } + + tryOnScopeDispose(stop); + + return { + isPending: readonly(isPending), + start, + stop, + }; +} diff --git a/vue/toolkit/src/composables/utilities/useTimestamp/index.test.ts b/vue/toolkit/src/composables/utilities/useTimestamp/index.test.ts new file mode 100644 index 0000000..9c0c0d0 --- /dev/null +++ b/vue/toolkit/src/composables/utilities/useTimestamp/index.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ref } from 'vue'; +import { useTimestamp } from '.'; + +describe(useTimestamp, () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(1000); + }); + afterEach(() => vi.useRealTimers()); + + it('returns the current timestamp', () => { + const ts = useTimestamp({ interval: 100 }); + expect(ts.value).toBe(1000); + }); + + it('applies the offset', () => { + const ts = useTimestamp({ interval: 100, offset: 500 }); + expect(ts.value).toBe(1500); + }); + + it('updates on the interval', () => { + const ts = useTimestamp({ interval: 100 }); + + // advanceTimersByTime also advances the mocked clock, so the tick fires at 1100 + vi.advanceTimersByTime(100); + expect(ts.value).toBe(1100); + }); + + it('exposes controls when controls: true', () => { + const { timestamp, pause } = useTimestamp({ controls: true, interval: 100 }); + + vi.advanceTimersByTime(100); + expect(timestamp.value).toBe(1100); + + pause(); + vi.advanceTimersByTime(100); + expect(timestamp.value).toBe(1100); + }); + + it('exposes isActive in the controls and reflects pause/resume', () => { + const { isActive, pause, resume } = useTimestamp({ controls: true, interval: 100 }); + + expect(isActive.value).toBeTruthy(); + + pause(); + expect(isActive.value).toBeFalsy(); + + resume(); + expect(isActive.value).toBeTruthy(); + }); + + it('does not start updating when immediate is false', () => { + const { timestamp, isActive } = useTimestamp({ controls: true, interval: 100, immediate: false }); + + expect(isActive.value).toBeFalsy(); + + vi.advanceTimersByTime(100); + expect(timestamp.value).toBe(1000); + }); + + it('invokes the callback on every update with the current timestamp', () => { + const callback = vi.fn(); + useTimestamp({ interval: 100, callback }); + + vi.advanceTimersByTime(100); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenLastCalledWith(1100); + + vi.advanceTimersByTime(100); + expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenLastCalledWith(1200); + }); + + it('supports a reactive offset that recomputes on the next update', () => { + const offset = ref(0); + const ts = useTimestamp({ interval: 100, offset }); + + expect(ts.value).toBe(1000); + + offset.value = 500; + vi.advanceTimersByTime(100); + expect(ts.value).toBe(1600); + }); + + it('supports a getter offset', () => { + let extra = 0; + const ts = useTimestamp({ interval: 100, offset: () => extra }); + + expect(ts.value).toBe(1000); + + extra = 250; + vi.advanceTimersByTime(100); + expect(ts.value).toBe(1350); + }); +}); diff --git a/vue/toolkit/src/composables/utilities/useTimestamp/index.ts b/vue/toolkit/src/composables/utilities/useTimestamp/index.ts new file mode 100644 index 0000000..4f96daf --- /dev/null +++ b/vue/toolkit/src/composables/utilities/useTimestamp/index.ts @@ -0,0 +1,123 @@ +import { shallowRef, toValue } from 'vue'; +import type { MaybeRefOrGetter, Ref } from 'vue'; +import { timestamp } from '@robonen/stdlib'; +import type { ResumableActions } from '@/types'; +import { useRafFn } from '@/composables/browser/useRafFn'; +import { useIntervalFn } from '@/composables/browser/useIntervalFn'; + +export interface UseTimestampOptions<Controls extends boolean> { + /** + * Expose pause/resume controls alongside the timestamp + * + * @default false + */ + controls?: Controls; + + /** + * Offset added to the timestamp in milliseconds. Accepts a reactive value + * (ref or getter); the timestamp recomputes with the latest offset on the + * next update. + * + * @default 0 + */ + offset?: MaybeRefOrGetter<number>; + + /** + * Start updating immediately + * + * @default true + */ + immediate?: boolean; + + /** + * Update strategy. `'requestAnimationFrame'` updates every frame; a number + * updates on a fixed interval (ms). + * + * @default 'requestAnimationFrame' + */ + interval?: 'requestAnimationFrame' | number; + + /** + * Callback invoked on every update with the current timestamp + */ + callback?: (timestamp: number) => void; +} + +/** + * Pause/resume controls returned when `controls: true`. + */ +export interface UseTimestampControls extends ResumableActions { + /** + * The reactive timestamp + */ + timestamp: Ref<number>; + + /** + * Whether the updater (RAF loop or interval) is currently active + */ + isActive: Readonly<Ref<boolean>>; +} + +export type UseTimestampReturn<Controls extends boolean> = Controls extends true + ? UseTimestampControls + : Ref<number>; + +/** + * @name useTimestamp + * @category Utilities + * @description Reactive current timestamp, updated via `requestAnimationFrame` + * or a fixed interval. + * + * @param {UseTimestampOptions} [options={}] Options + * @returns {Ref<number> | UseTimestampControls} The timestamp, or controls when `controls: true` + * + * @example + * const now = useTimestamp(); + * + * @example + * const { timestamp, pause, resume, isActive } = useTimestamp({ controls: true, interval: 1000 }); + * + * @example + * // Reactive offset + * const offset = ref(0); + * const now = useTimestamp({ offset }); + * + * @since 0.0.15 + */ +export function useTimestamp(options?: UseTimestampOptions<false>): Ref<number>; +export function useTimestamp(options: UseTimestampOptions<true>): UseTimestampControls; +export function useTimestamp( + options: UseTimestampOptions<boolean> = {}, +): Ref<number> | UseTimestampControls { + const { + controls = false, + offset = 0, + immediate = true, + interval = 'requestAnimationFrame', + callback, + } = options; + + const ts = shallowRef(timestamp() + toValue(offset)); + + const update = callback + ? () => { + ts.value = timestamp() + toValue(offset); + callback(ts.value); + } + : () => { + ts.value = timestamp() + toValue(offset); + }; + + const resumableControls = interval === 'requestAnimationFrame' + ? useRafFn(update, { immediate }) + : useIntervalFn(update, interval, { immediate }); + + if (controls) { + return { + timestamp: ts, + ...resumableControls, + }; + } + + return ts; +} diff --git a/vue/toolkit/src/types/window.ts b/vue/toolkit/src/types/window.ts index a0acb2f..134c7d0 100644 --- a/vue/toolkit/src/types/window.ts +++ b/vue/toolkit/src/types/window.ts @@ -1,6 +1,8 @@ import { isClient } from '@robonen/platform/multi'; export const defaultWindow = /* #__PURE__ */ isClient ? globalThis as Window & typeof globalThis : undefined; +export const defaultDocument = /* #__PURE__ */ isClient ? globalThis.document : undefined; +export const defaultNavigator = /* #__PURE__ */ isClient ? globalThis.navigator : undefined; export interface ConfigurableWindow { /** @@ -10,3 +12,21 @@ export interface ConfigurableWindow { */ window?: Window; } + +export interface ConfigurableDocument { + /** + * Specify a custom `document` instance, e.g. working with iframes or testing environments + * + * @default defaultDocument + */ + document?: Document; +} + +export interface ConfigurableNavigator { + /** + * Specify a custom `navigator` instance, e.g. working with testing environments + * + * @default defaultNavigator + */ + navigator?: Navigator; +} diff --git a/vue/toolkit/src/utils/filters.test.ts b/vue/toolkit/src/utils/filters.test.ts index f96b2fe..e1b82a6 100644 --- a/vue/toolkit/src/utils/filters.test.ts +++ b/vue/toolkit/src/utils/filters.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ref } from 'vue'; -import { bypassFilter, debounceFilter, throttleFilter, pausableFilter } from './filters'; +import { bypassFilter, debounceFilter, pausableFilter, throttleFilter } from './filters'; describe(bypassFilter, () => { it('invokes callback immediately', () => { diff --git a/vue/toolkit/src/utils/filters.ts b/vue/toolkit/src/utils/filters.ts index 7eaca5e..55ed493 100644 --- a/vue/toolkit/src/utils/filters.ts +++ b/vue/toolkit/src/utils/filters.ts @@ -1,5 +1,7 @@ import { ref, toValue } from 'vue'; import type { MaybeRefOrGetter, Ref } from 'vue'; +import { debounce, throttle } from '@robonen/stdlib'; +import type { AnyFunction } from '@robonen/stdlib'; export type EventFilter = (invoke: () => void) => void; @@ -13,6 +15,14 @@ export interface ConfigurableEventFilter { eventFilter?: EventFilter; } +export interface DebounceFilterOptions { + /** + * The maximum time the callback may be delayed before it is forcibly invoked + * under sustained input (can be reactive). When omitted there is no upper bound. + */ + maxWait?: MaybeRefOrGetter<number>; +} + /** * A no-op filter that invokes the callback immediately */ @@ -21,29 +31,37 @@ export const bypassFilter: EventFilter = (invoke) => { }; /** - * Create a debounce event filter + * Create a debounce event filter — a reactive-aware wrapper around + * `@robonen/stdlib`'s `debounce` (trailing edge). * * @param ms Delay in milliseconds (can be reactive) + * @param options Optional `maxWait` ceiling (can be reactive) * @returns EventFilter */ -export function debounceFilter(ms: MaybeRefOrGetter<number>): EventFilter { - let timer: ReturnType<typeof setTimeout> | undefined; +export function debounceFilter(ms: MaybeRefOrGetter<number>, options: DebounceFilterOptions = {}): EventFilter { + const { maxWait } = options; - const filter: EventFilter = (invoke) => { - if (timer !== undefined) - clearTimeout(timer); + const debounced = debounce( + (invoke: () => void) => invoke(), + () => toValue(ms), + { maxWait: maxWait === undefined ? undefined : () => toValue(maxWait) }, + ); - timer = setTimeout(() => { - timer = undefined; + return (invoke) => { + // Non-positive delay runs synchronously (behaves like an un-debounced call). + if (toValue(ms) <= 0) { + debounced.cancel(); invoke(); - }, toValue(ms)); - }; + return; + } - return filter; + debounced(invoke); + }; } /** - * Create a throttle event filter + * Create a throttle event filter — a reactive-aware wrapper around + * `@robonen/stdlib`'s `throttle`. * * @param ms Interval in milliseconds (can be reactive) * @param trailing Whether to invoke on trailing edge (default: true) @@ -55,46 +73,13 @@ export function throttleFilter( trailing = true, leading = true, ): EventFilter { - let lastExec = 0; - let timer: ReturnType<typeof setTimeout> | undefined; - let lastInvoke: (() => void) | undefined; - let isLeading = true; + const throttled = throttle( + (invoke: () => void) => invoke(), + () => toValue(ms), + { trailing, leading }, + ); - const clear = () => { - if (timer !== undefined) { - clearTimeout(timer); - timer = undefined; - } - }; - - const filter: EventFilter = (invoke) => { - const duration = toValue(ms); - const elapsed = Date.now() - lastExec; - - lastInvoke = invoke; - - if (elapsed >= duration && (leading || !isLeading)) { - lastExec = Date.now(); - isLeading = false; - invoke(); - clear(); - return; - } - - isLeading = false; - - if (trailing) { - clear(); - timer = setTimeout(() => { - lastExec = Date.now(); - isLeading = true; - timer = undefined; - lastInvoke?.(); - }, Math.max(0, duration - elapsed)); - } - }; - - return filter; + return invoke => throttled(invoke); } export interface PausableEventFilterReturn { @@ -136,3 +121,47 @@ export function pausableFilter(): PausableEventFilterReturn { }, }; } + +/** + * Wrap a function with an {@link EventFilter}, preserving arguments, `this`, + * and the return value through a promise. + * + * The wrapper returns a promise that resolves with the result of the wrapped + * function once the filter lets it through. When the filter coalesces calls + * (e.g. debounce/throttle), every pending promise scheduled since the last + * invocation resolves together with that invocation's result — so nothing is + * left dangling. + * + * @param filter The event filter controlling invocation timing + * @param fn The function to wrap + * @returns A filtered wrapper returning a promise of the result + */ +export function createFilterWrapper<T extends AnyFunction>( + filter: EventFilter, + fn: T, +): (...args: Parameters<T>) => Promise<Awaited<ReturnType<T>>> { + // Promises scheduled but not yet resolved by an invocation. The filter may + // drop intermediate invokes (debounce) — they all settle on the next real one. + let pending: Array<{ resolve: (value: any) => void; reject: (reason?: unknown) => void }> = []; + + function wrapper(this: unknown, ...args: Parameters<T>) { + return new Promise<Awaited<ReturnType<T>>>((resolve, reject) => { + pending.push({ resolve, reject }); + + filter(() => { + const settled = pending; + pending = []; + + try { + const result = fn.apply(this, args); + for (const p of settled) p.resolve(result); + } + catch (error) { + for (const p of settled) p.reject(error); + } + }); + }); + } + + return wrapper; +} diff --git a/vue/toolkit/tsconfig.json b/vue/toolkit/tsconfig.json index 0ca905e..2781e66 100644 --- a/vue/toolkit/tsconfig.json +++ b/vue/toolkit/tsconfig.json @@ -1,13 +1,7 @@ { - "extends": "@robonen/tsconfig/tsconfig.json", - "compilerOptions": { - "lib": ["DOM"], - "baseUrl": ".", - "paths": { - "@/*": ["src/*"], - "@/composables/*": ["src/composables/*"], - "@/types": ["src/types"], - "@/utils": ["src/utils"] - } - } -} \ No newline at end of file + "files": [], + "references": [ + { "path": "./tsconfig.src.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/vue/toolkit/tsconfig.node.json b/vue/toolkit/tsconfig.node.json new file mode 100644 index 0000000..edc474f --- /dev/null +++ b/vue/toolkit/tsconfig.node.json @@ -0,0 +1,8 @@ +{ + "extends": "@robonen/tsconfig/tsconfig.node.json", + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo" + }, + "include": ["*.config.ts"] +} diff --git a/vue/toolkit/tsconfig.src.json b/vue/toolkit/tsconfig.src.json new file mode 100644 index 0000000..5482396 --- /dev/null +++ b/vue/toolkit/tsconfig.src.json @@ -0,0 +1,15 @@ +{ + "extends": "@robonen/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "composite": true, + "types": [], + "paths": { + "@/*": ["./src/*"], + "@/composables/*": ["./src/composables/*"], + "@/types": ["./src/types"], + "@/utils": ["./src/utils"] + }, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.src.tsbuildinfo" + }, + "include": ["src/**/*.ts"] +} diff --git a/vue/toolkit/tsdown.config.ts b/vue/toolkit/tsdown.config.ts index 0037e2f..0907e4b 100644 --- a/vue/toolkit/tsdown.config.ts +++ b/vue/toolkit/tsdown.config.ts @@ -3,6 +3,7 @@ import { sharedConfig } from '@robonen/tsdown'; export default defineConfig({ ...sharedConfig, + tsconfig: './tsconfig.src.json', entry: ['src/index.ts'], external: ['vue'], noExternal: [/^@robonen\//],