feat(vue): expand @robonen/vue composable collection
Composables, tests, category barrels, and README for @robonen/vue.
This commit is contained in:
+22
-15
@@ -1,6 +1,6 @@
|
||||
# @robonen/vue
|
||||
|
||||
Collection of composables and utilities for Vue 3 — 100+ tree-shakeable, SSR-safe composables.
|
||||
Collection of composables and utilities for Vue 3 — 213+ tree-shakeable, SSR-safe composables.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -10,32 +10,39 @@ pnpm install @robonen/vue
|
||||
|
||||
## Composables
|
||||
|
||||
| Category | Composables |
|
||||
| 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`, `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** | `useDebounceFn`, `useInterval`, `useOffsetPagination`, `useThrottleFn`, `useTimeoutFn`, `useTimestamp` |
|
||||
| **animation** | `useAnimate`, `useCountdown`, `useDateFormat`, `useInterval`, `useIntervalFn`, `useNow`, `useRafFn`, `useTimeAgo`, `useTimeout`, `useTimeoutFn`, `useTimestamp`, `useTransition` |
|
||||
| **array** | `useArrayDifference`, `useArrayEvery`, `useArrayFilter`, `useArrayFind`, `useArrayFindIndex`, `useArrayFindLast`, `useArrayIncludes`, `useArrayJoin`, `useArrayMap`, `useArrayReduce`, `useArraySome`, `useArrayUnique`, `useSorted` |
|
||||
| **browser** | `broadcastedRef`, `useBreakpoints`, `useClipboard`, `useClipboardItems`, `useCloseWatcher`, `useColorMode`, `useCssVar`, `useDark`, `useDocumentPiP`, `useEventListener`, `useEyeDropper`, `useFavicon`, `useFileDialog`, `useFileSystemAccess`, `useFullscreen`, `useImage`, `useLocalFonts`, `useMediaQuery`, `useObjectUrl`, `usePermission`, `usePreferredColorScheme`, `usePreferredContrast`, `usePreferredDark`, `usePreferredLanguages`, `usePreferredReducedMotion`, `usePreferredReducedTransparency`, `useScriptTag`, `useShare`, `useStyleTag`, `useTabLeader`, `useTextareaAutosize`, `useTitle`, `useUrlSearchParams`, `useVibrate`, `useWakeLock`, `useWebNotification` |
|
||||
| **component** | `createReusableTemplate`, `unrefElement`, `useCurrentElement`, `useForwardExpose`, `useTemplateRefsList`, `useVirtualList` |
|
||||
| **debug** | `useRenderCount`, `useRenderInfo` |
|
||||
| **elements** | `onElementRemoval`, `useActiveElement`, `useDocumentReadyState`, `useDocumentVisibility`, `useDraggable`, `useDropZone`, `useElementBounding`, `useElementSize`, `useElementVisibility`, `useFocusGuard`, `useIntersectionObserver`, `useMutationObserver`, `useParentElement`, `useResizeObserver`, `useWindowFocus`, `useWindowScroll`, `useWindowSize` |
|
||||
| **forms** | `useField`, `useFieldArray`, `useForm`, `useFormContext` |
|
||||
| **lifecycle** | `tryOnBeforeMount`, `tryOnMounted`, `tryOnScopeDispose`, `useMounted` |
|
||||
| **math** | `logicAnd`, `logicNot`, `logicOr`, `useAbs`, `useAverage`, `useCeil`, `useClamp`, `useFloor`, `useMath`, `useMax`, `useMin`, `usePrecision`, `useProjection`, `useRound`, `useSum`, `useTrunc` |
|
||||
| **media** | `useBluetooth`, `useDisplayMedia`, `useMediaControls`, `useMemory`, `usePerformanceObserver`, `useSpeechRecognition`, `useSpeechSynthesis`, `useUserMedia`, `useWebWorker`, `useWebWorkerFn` |
|
||||
| **reactivity** | `computedAsync`, `computedEager`, `computedWithControl`, `extendRef`, `reactiveComputed`, `reactiveOmit`, `reactivePick`, `refAutoReset`, `refDebounced`, `refDefault`, `refThrottled`, `refWithControl`, `syncRef`, `toReactive`, `useCached`, `useCloned`, `useDebounceFn`, `usePrevious`, `useSyncRefs`, `useThrottleFn`, `useToNumber`, `useToString` |
|
||||
| **sensors** | `onKeyStroke`, `onLongPress`, `onStartTyping`, `useBattery`, `useBodyScrollLock`, `useClickOutside`, `useDeviceMotion`, `useDeviceOrientation`, `useDevicePixelRatio`, `useDevicesList`, `useElementByPoint`, `useElementHover`, `useEscapeKey`, `useFocus`, `useFocusWithin`, `useFps`, `useGamepad`, `useGeolocation`, `useIdle`, `useInfiniteScroll`, `useKeyModifier`, `useMagicKeys`, `useMouse`, `useMouseInElement`, `useMousePressed`, `useNetwork`, `useOnline`, `usePageLeave`, `useParallax`, `usePointer`, `usePointerLock`, `usePointerSwipe`, `useScreenOrientation`, `useScroll`, `useScrollLock`, `useSwipe`, `useTextSelection` |
|
||||
| **state** | `createSharedComposable`, `useAppSharedState`, `useAsyncState`, `useContextFactory`, `useCounter`, `useCycleList`, `useDebouncedRefHistory`, `useId`, `useInjectionStore`, `useLastChanged`, `useManualRefHistory`, `useOffsetPagination`, `useRefHistory`, `useStepper`, `useThrottledRefHistory`, `useToggle` |
|
||||
| **storage** | `useLocalStorage`, `useSessionStorage`, `useStorage`, `useStorageAsync` |
|
||||
| **utilities** | `createEventHook`, `get`, `isDefined`, `set`, `useEventBus`, `useMemoize`, `useSupported` |
|
||||
| **watch** | `until`, `watchDebounced`, `watchIgnorable`, `watchOnce`, `watchPausable`, `watchThrottled`, `whenever` |
|
||||
|
||||
The package also exports event-filter helpers (`debounceFilter`, `throttleFilter`, `pausableFilter`, `createFilterWrapper`) and shared types (`ConfigurableWindow`, `ConfigurableDocument`, `ConfigurableNavigator`, `MaybeComputedElementRef`, …).
|
||||
The package also exports event-filter helpers (`debounceFilter`, `throttleFilter`, `pausableFilter`, `createFilterWrapper`) and shared types (`ConfigurableWindow`, `ConfigurableDocument`, `ConfigurableNavigator`, `ConfigurableFlush`, `MaybeComputedElementRef`, …).
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { useEventListener, useMagicKeys, useToggle } from '@robonen/vue';
|
||||
import { useEventListener, useMagicKeys, useToggle } from @robonen/vue;
|
||||
|
||||
const { value, toggle } = useToggle();
|
||||
|
||||
useEventListener('scroll', () => {/* … */}, { passive: true });
|
||||
useEventListener(scroll, () => {/* … */}, { passive: true });
|
||||
|
||||
const keys = useMagicKeys();
|
||||
watchEffect(() => {
|
||||
if (keys['ctrl+s'].value)
|
||||
if (keys[ctrl+s].value)
|
||||
save();
|
||||
});
|
||||
```
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export * from './useAnimate';
|
||||
export * from './useCountdown';
|
||||
export * from './useDateFormat';
|
||||
export * from './useInterval';
|
||||
export * from './useIntervalFn';
|
||||
export * from './useNow';
|
||||
export * from './useRafFn';
|
||||
export * from './useTimeAgo';
|
||||
export * from './useTimeout';
|
||||
export * from './useTimeoutFn';
|
||||
export * from './useTimestamp';
|
||||
export * from './useTransition';
|
||||
@@ -0,0 +1,355 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick, ref } from 'vue';
|
||||
import { useAnimate } from '.';
|
||||
|
||||
// --- Stub Animation -------------------------------------------------------
|
||||
|
||||
type Listener = (ev: any) => void;
|
||||
|
||||
class StubAnimation {
|
||||
effect: any;
|
||||
timeline: any = { kind: 'document' };
|
||||
startTime: number | null = null;
|
||||
currentTime: number | null = 0;
|
||||
playbackRate = 1;
|
||||
pending = false;
|
||||
playState: AnimationPlayState = 'idle';
|
||||
replaceState: AnimationReplaceState = 'active';
|
||||
|
||||
play = vi.fn(() => {
|
||||
this.playState = 'running';
|
||||
});
|
||||
|
||||
pause = vi.fn(() => {
|
||||
this.playState = 'paused';
|
||||
});
|
||||
|
||||
reverse = vi.fn(() => {
|
||||
this.playbackRate = -1;
|
||||
this.playState = 'running';
|
||||
});
|
||||
|
||||
finish = vi.fn(() => {
|
||||
this.playState = 'finished';
|
||||
});
|
||||
|
||||
cancel = vi.fn(() => {
|
||||
this.playState = 'idle';
|
||||
});
|
||||
|
||||
persist = vi.fn();
|
||||
commitStyles = vi.fn();
|
||||
|
||||
private listeners = new Map<string, Set<Listener>>();
|
||||
|
||||
addEventListener = vi.fn((type: string, listener: Listener) => {
|
||||
if (!this.listeners.has(type))
|
||||
this.listeners.set(type, new Set());
|
||||
this.listeners.get(type)!.add(listener);
|
||||
});
|
||||
|
||||
removeEventListener = vi.fn((type: string, listener: Listener) => {
|
||||
this.listeners.get(type)?.delete(listener);
|
||||
});
|
||||
|
||||
dispatch(type: string) {
|
||||
this.listeners.get(type)?.forEach(l => l({ type }));
|
||||
}
|
||||
|
||||
constructor(public keyframes: any, public options: any) {
|
||||
instances.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
let instances: StubAnimation[] = [];
|
||||
let animateSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
// Flush enough rAF + microtasks for the useRafFn store sync to run.
|
||||
async function flushFrames(count = 3) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
|
||||
describe(useAnimate, () => {
|
||||
beforeEach(() => {
|
||||
instances = [];
|
||||
animateSpy = vi.fn(function (this: HTMLElement, keyframes: any, options: any) {
|
||||
return new StubAnimation(keyframes, options) as unknown as Animation;
|
||||
});
|
||||
// jsdom does not implement the Web Animations API
|
||||
Object.defineProperty(HTMLElement.prototype, 'animate', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: animateSpy,
|
||||
});
|
||||
vi.stubGlobal('KeyframeEffect', class {
|
||||
constructor(public el: any, public kf: any, public opts: any) {}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
delete (HTMLElement.prototype as any).animate;
|
||||
});
|
||||
|
||||
it('reports support when Element.animate exists', () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let api!: ReturnType<typeof useAnimate>;
|
||||
scope.run(() => {
|
||||
api = useAnimate(ref(el), [{ opacity: 0 }, { opacity: 1 }], 1000);
|
||||
});
|
||||
|
||||
expect(api.isSupported.value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('creates the animation immediately for a resolved target', async () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let api!: ReturnType<typeof useAnimate>;
|
||||
scope.run(() => {
|
||||
api = useAnimate(ref(el), [{ opacity: 0 }, { opacity: 1 }], 1000);
|
||||
});
|
||||
|
||||
await flushFrames();
|
||||
|
||||
expect(animateSpy).toHaveBeenCalledTimes(1);
|
||||
expect(animateSpy).toHaveBeenCalledWith([{ opacity: 0 }, { opacity: 1 }], 1000);
|
||||
expect(api.animate.value).toBe(instances[0] as unknown as Animation);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('passes an options object through to animate, stripping reserved keys', async () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
useAnimate(ref(el), { opacity: [0, 1] }, {
|
||||
duration: 500,
|
||||
easing: 'ease-in',
|
||||
immediate: true,
|
||||
commitStyles: true,
|
||||
});
|
||||
});
|
||||
|
||||
await flushFrames();
|
||||
|
||||
expect(animateSpy).toHaveBeenCalledWith({ opacity: [0, 1] }, { duration: 500, easing: 'ease-in' });
|
||||
expect(instances[0]!.options).not.toHaveProperty('immediate');
|
||||
expect(instances[0]!.options).not.toHaveProperty('commitStyles');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not auto-play when immediate is false', async () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let api!: ReturnType<typeof useAnimate>;
|
||||
scope.run(() => {
|
||||
api = useAnimate(ref(el), [{ opacity: 0 }], { duration: 1000, immediate: false });
|
||||
});
|
||||
|
||||
await flushFrames();
|
||||
|
||||
expect(instances[0]!.pause).toHaveBeenCalled();
|
||||
expect(api.animate.value).toBeDefined();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('play / pause / reverse / finish / cancel delegate to the Animation', async () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let api!: ReturnType<typeof useAnimate>;
|
||||
scope.run(() => {
|
||||
api = useAnimate(ref(el), [{ opacity: 0 }], 1000);
|
||||
});
|
||||
|
||||
await flushFrames();
|
||||
const inst = instances[0]!;
|
||||
|
||||
api.pause();
|
||||
expect(inst.pause).toHaveBeenCalled();
|
||||
|
||||
api.play();
|
||||
expect(inst.play).toHaveBeenCalled();
|
||||
|
||||
api.reverse();
|
||||
expect(inst.reverse).toHaveBeenCalled();
|
||||
|
||||
api.finish();
|
||||
expect(inst.finish).toHaveBeenCalled();
|
||||
|
||||
api.cancel();
|
||||
expect(inst.cancel).toHaveBeenCalled();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('syncs reactive playState from the live animation', async () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let api!: ReturnType<typeof useAnimate>;
|
||||
scope.run(() => {
|
||||
api = useAnimate(ref(el), [{ opacity: 0 }], 1000);
|
||||
});
|
||||
|
||||
await flushFrames();
|
||||
const inst = instances[0]!;
|
||||
|
||||
inst.playState = 'running';
|
||||
inst.currentTime = 42;
|
||||
api.play();
|
||||
await flushFrames();
|
||||
|
||||
expect(api.playState.value).toBe('running');
|
||||
expect(api.currentTime.value).toBe(42);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('writes currentTime back to the animation', async () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let api!: ReturnType<typeof useAnimate>;
|
||||
scope.run(() => {
|
||||
api = useAnimate(ref(el), [{ opacity: 0 }], 1000);
|
||||
});
|
||||
|
||||
await flushFrames();
|
||||
const inst = instances[0]!;
|
||||
|
||||
api.currentTime.value = 250;
|
||||
expect(inst.currentTime).toBe(250);
|
||||
expect(api.currentTime.value).toBe(250);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('writes playbackRate back to the animation', async () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let api!: ReturnType<typeof useAnimate>;
|
||||
scope.run(() => {
|
||||
api = useAnimate(ref(el), [{ opacity: 0 }], 1000);
|
||||
});
|
||||
|
||||
await flushFrames();
|
||||
const inst = instances[0]!;
|
||||
|
||||
api.playbackRate.value = 2;
|
||||
expect(inst.playbackRate).toBe(2);
|
||||
expect(api.playbackRate.value).toBe(2);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('applies persist and initial playbackRate options', async () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
useAnimate(ref(el), [{ opacity: 0 }], { duration: 1000, persist: true, playbackRate: 3 });
|
||||
});
|
||||
|
||||
await flushFrames();
|
||||
const inst = instances[0]!;
|
||||
|
||||
expect(inst.persist).toHaveBeenCalled();
|
||||
expect(inst.playbackRate).toBe(3);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('commits styles on finish when commitStyles is set', async () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
useAnimate(ref(el), [{ opacity: 0 }], { duration: 1000, commitStyles: true });
|
||||
});
|
||||
|
||||
await flushFrames();
|
||||
const inst = instances[0]!;
|
||||
|
||||
inst.dispatch('finish');
|
||||
expect(inst.commitStyles).toHaveBeenCalled();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('calls onReady with the created animation', async () => {
|
||||
const el = document.createElement('div');
|
||||
const onReady = vi.fn();
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
useAnimate(ref(el), [{ opacity: 0 }], { duration: 1000, onReady });
|
||||
});
|
||||
|
||||
await flushFrames();
|
||||
|
||||
expect(onReady).toHaveBeenCalledWith(instances[0]);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('routes thrown errors to onError instead of throwing', async () => {
|
||||
const el = document.createElement('div');
|
||||
const onError = vi.fn();
|
||||
const scope = effectScope();
|
||||
let api!: ReturnType<typeof useAnimate>;
|
||||
scope.run(() => {
|
||||
api = useAnimate(ref(el), [{ opacity: 0 }], { duration: 1000, onError });
|
||||
});
|
||||
|
||||
await flushFrames();
|
||||
const inst = instances[0]!;
|
||||
const boom = new Error('boom');
|
||||
inst.play.mockImplementationOnce(() => {
|
||||
throw boom;
|
||||
});
|
||||
|
||||
expect(() => api.play()).not.toThrow();
|
||||
expect(onError).toHaveBeenCalledWith(boom);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('clears the animation when the target becomes null', async () => {
|
||||
const el = document.createElement('div');
|
||||
const target = ref<HTMLElement | null>(el);
|
||||
const scope = effectScope();
|
||||
let api!: ReturnType<typeof useAnimate>;
|
||||
scope.run(() => {
|
||||
api = useAnimate(target, [{ opacity: 0 }], 1000);
|
||||
});
|
||||
|
||||
await flushFrames();
|
||||
expect(api.animate.value).toBeDefined();
|
||||
|
||||
target.value = null;
|
||||
await nextTick();
|
||||
expect(api.animate.value).toBeUndefined();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('cancels the animation on scope dispose', async () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
useAnimate(ref(el), [{ opacity: 0 }], 1000);
|
||||
});
|
||||
|
||||
await flushFrames();
|
||||
const inst = instances[0]!;
|
||||
|
||||
scope.stop();
|
||||
expect(inst.cancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is not supported and never animates when window is undefined (SSR)', async () => {
|
||||
const el = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let api!: ReturnType<typeof useAnimate>;
|
||||
scope.run(() => {
|
||||
api = useAnimate(ref(el), [{ opacity: 0 }], { duration: 1000, window: undefined });
|
||||
});
|
||||
|
||||
await flushFrames();
|
||||
|
||||
expect(api.isSupported.value).toBeFalsy();
|
||||
expect(animateSpy).not.toHaveBeenCalled();
|
||||
expect(api.animate.value).toBeUndefined();
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,417 @@
|
||||
import { computed, shallowReactive, shallowRef, toValue, watch } from 'vue';
|
||||
import type { ComputedRef, MaybeRef, Ref, ShallowRef, WritableComputedRef } from 'vue';
|
||||
import { isObject, noop, omit } from '@robonen/stdlib';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { unrefElement } from '@/composables/component/unrefElement';
|
||||
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
||||
import { useSupported } from '@/composables/utilities/useSupported';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
import { useRafFn } from '@/composables/animation/useRafFn';
|
||||
import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
|
||||
export interface UseAnimateOptions extends KeyframeAnimationOptions, ConfigurableWindow {
|
||||
/**
|
||||
* Automatically call `play()` once the target element is resolved.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
immediate?: boolean;
|
||||
|
||||
/**
|
||||
* Commit the end styling state of the animation to the element when it finishes.
|
||||
* Usually paired with the `fill` option.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
commitStyles?: boolean;
|
||||
|
||||
/**
|
||||
* Persist the animation so it is not automatically removed by the browser.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
persist?: boolean;
|
||||
|
||||
/**
|
||||
* Called once the underlying `Animation` instance has been created.
|
||||
*/
|
||||
onReady?: (animation: Animation) => void;
|
||||
|
||||
/**
|
||||
* Called when an error is thrown while controlling the animation.
|
||||
*
|
||||
* @default noop
|
||||
*/
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export type UseAnimateKeyframes
|
||||
= MaybeRef<Keyframe[] | PropertyIndexedKeyframes | null>;
|
||||
|
||||
export interface UseAnimateReturn {
|
||||
/**
|
||||
* Whether the Web Animations API is supported in the current environment
|
||||
*/
|
||||
isSupported: Readonly<Ref<boolean>>;
|
||||
|
||||
/**
|
||||
* The underlying `Animation` instance, or `undefined` before it is created
|
||||
*/
|
||||
animate: ShallowRef<Animation | undefined>;
|
||||
|
||||
/**
|
||||
* Start or resume the animation
|
||||
*/
|
||||
play: () => void;
|
||||
|
||||
/**
|
||||
* Suspend playback of the animation
|
||||
*/
|
||||
pause: () => void;
|
||||
|
||||
/**
|
||||
* Reverse the playback direction of the animation
|
||||
*/
|
||||
reverse: () => void;
|
||||
|
||||
/**
|
||||
* Seek the animation to the end of its active duration
|
||||
*/
|
||||
finish: () => void;
|
||||
|
||||
/**
|
||||
* Abort the animation, clearing its effects
|
||||
*/
|
||||
cancel: () => void;
|
||||
|
||||
/**
|
||||
* Whether the animation is currently waiting for an asynchronous operation
|
||||
*/
|
||||
pending: ComputedRef<boolean>;
|
||||
|
||||
/**
|
||||
* The current playback state of the animation
|
||||
*/
|
||||
playState: ComputedRef<AnimationPlayState>;
|
||||
|
||||
/**
|
||||
* The current replace state of the animation
|
||||
*/
|
||||
replaceState: ComputedRef<AnimationReplaceState>;
|
||||
|
||||
/**
|
||||
* The scheduled time at which the animation should begin (writable)
|
||||
*/
|
||||
startTime: WritableComputedRef<CSSNumberish | number | null>;
|
||||
|
||||
/**
|
||||
* The current time value of the animation in milliseconds (writable)
|
||||
*/
|
||||
currentTime: WritableComputedRef<CSSNumberish | null>;
|
||||
|
||||
/**
|
||||
* The timeline associated with the animation (writable)
|
||||
*/
|
||||
timeline: WritableComputedRef<AnimationTimeline | null>;
|
||||
|
||||
/**
|
||||
* The playback rate of the animation (writable)
|
||||
*/
|
||||
playbackRate: WritableComputedRef<number>;
|
||||
}
|
||||
|
||||
interface AnimateStore {
|
||||
startTime: CSSNumberish | number | null;
|
||||
currentTime: CSSNumberish | null;
|
||||
timeline: AnimationTimeline | null;
|
||||
playbackRate: number;
|
||||
pending: boolean;
|
||||
playState: AnimationPlayState;
|
||||
replaceState: AnimationReplaceState;
|
||||
}
|
||||
|
||||
const RESERVED_KEYS = [
|
||||
'window',
|
||||
'immediate',
|
||||
'commitStyles',
|
||||
'persist',
|
||||
'onReady',
|
||||
'onError',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* @name useAnimate
|
||||
* @category Animation
|
||||
* @description Reactive [Web Animations API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API)
|
||||
* wrapper for a single element. Exposes imperative controls (`play`, `pause`, `reverse`,
|
||||
* `finish`, `cancel`) alongside reactive state (`playState`, `currentTime`, `playbackRate`, ...).
|
||||
* The reactive state is synced via `requestAnimationFrame` only while the animation is running,
|
||||
* so an idle animation costs nothing. SSR-safe: nothing touches the DOM until the element resolves.
|
||||
*
|
||||
* @param {MaybeComputedElementRef} target Element to animate (reactive ref, getter, or element)
|
||||
* @param {UseAnimateKeyframes} keyframes Keyframes to animate, reactive
|
||||
* @param {number | UseAnimateOptions} [options] Duration in ms, or full options object
|
||||
* @returns {UseAnimateReturn} Support flag, the `Animation` instance, controls, and reactive state
|
||||
*
|
||||
* @example
|
||||
* const el = useTemplateRef<HTMLElement>('el');
|
||||
* const { playState, play, pause } = useAnimate(
|
||||
* el,
|
||||
* [{ transform: 'rotate(0)' }, { transform: 'rotate(360deg)' }],
|
||||
* { duration: 1000, iterations: Infinity },
|
||||
* );
|
||||
*
|
||||
* @example
|
||||
* // Shorthand: third argument is the duration in milliseconds
|
||||
* useAnimate(el, { opacity: [0, 1] }, 500);
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useAnimate(
|
||||
target: MaybeComputedElementRef,
|
||||
keyframes: UseAnimateKeyframes,
|
||||
options?: number | UseAnimateOptions,
|
||||
): UseAnimateReturn {
|
||||
let config: UseAnimateOptions;
|
||||
let animateOptions: number | KeyframeAnimationOptions | undefined;
|
||||
|
||||
if (isObject(options)) {
|
||||
config = options;
|
||||
animateOptions = omit(options, RESERVED_KEYS as unknown as Array<keyof UseAnimateOptions>);
|
||||
}
|
||||
else {
|
||||
config = { duration: options };
|
||||
animateOptions = options;
|
||||
}
|
||||
|
||||
const {
|
||||
immediate = true,
|
||||
commitStyles = false,
|
||||
persist = false,
|
||||
playbackRate: initialPlaybackRate = 1,
|
||||
onReady,
|
||||
onError = noop,
|
||||
} = config;
|
||||
|
||||
// Honor an explicit `window: undefined` (SSR / opt-out) rather than letting a
|
||||
// default parameter silently restore `defaultWindow`.
|
||||
const window = 'window' in config ? config.window : defaultWindow;
|
||||
|
||||
const isSupported = useSupported(() =>
|
||||
Boolean(window) && typeof HTMLElement !== 'undefined' && 'animate' in HTMLElement.prototype);
|
||||
|
||||
const animate = shallowRef<Animation | undefined>(undefined);
|
||||
|
||||
const store = shallowReactive<AnimateStore>({
|
||||
startTime: null,
|
||||
currentTime: null,
|
||||
timeline: null,
|
||||
playbackRate: initialPlaybackRate,
|
||||
pending: false,
|
||||
playState: immediate ? 'idle' : 'paused',
|
||||
replaceState: 'active',
|
||||
});
|
||||
|
||||
const pending = computed(() => store.pending);
|
||||
const playState = computed(() => store.playState);
|
||||
const replaceState = computed(() => store.replaceState);
|
||||
|
||||
const startTime = computed<CSSNumberish | number | null>({
|
||||
get: () => store.startTime,
|
||||
set(value) {
|
||||
store.startTime = value;
|
||||
if (animate.value)
|
||||
animate.value.startTime = value;
|
||||
},
|
||||
});
|
||||
|
||||
const currentTime = computed<CSSNumberish | null>({
|
||||
get: () => store.currentTime,
|
||||
set(value) {
|
||||
store.currentTime = value;
|
||||
if (animate.value) {
|
||||
animate.value.currentTime = value;
|
||||
syncResume();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const timeline = computed<AnimationTimeline | null>({
|
||||
get: () => store.timeline,
|
||||
set(value) {
|
||||
store.timeline = value;
|
||||
if (animate.value)
|
||||
animate.value.timeline = value;
|
||||
},
|
||||
});
|
||||
|
||||
const playbackRate = computed<number>({
|
||||
get: () => store.playbackRate,
|
||||
set(value) {
|
||||
store.playbackRate = value;
|
||||
if (animate.value)
|
||||
animate.value.playbackRate = value;
|
||||
},
|
||||
});
|
||||
|
||||
function update(init?: boolean): void {
|
||||
const el = unrefElement(target);
|
||||
if (!isSupported.value || !el)
|
||||
return;
|
||||
|
||||
if (!animate.value)
|
||||
animate.value = (el as HTMLElement).animate(toValue(keyframes), animateOptions);
|
||||
|
||||
if (persist)
|
||||
animate.value.persist();
|
||||
|
||||
if (initialPlaybackRate !== 1)
|
||||
animate.value.playbackRate = initialPlaybackRate;
|
||||
|
||||
if (init && !immediate)
|
||||
animate.value.pause();
|
||||
else
|
||||
syncResume();
|
||||
|
||||
onReady?.(animate.value);
|
||||
}
|
||||
|
||||
function play(): void {
|
||||
if (!animate.value) {
|
||||
update();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
animate.value.play();
|
||||
syncResume();
|
||||
}
|
||||
catch (error) {
|
||||
syncPause();
|
||||
onError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function pause(): void {
|
||||
try {
|
||||
animate.value?.pause();
|
||||
syncPause();
|
||||
}
|
||||
catch (error) {
|
||||
onError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function reverse(): void {
|
||||
if (!animate.value)
|
||||
update();
|
||||
|
||||
try {
|
||||
animate.value?.reverse();
|
||||
syncResume();
|
||||
}
|
||||
catch (error) {
|
||||
syncPause();
|
||||
onError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function finish(): void {
|
||||
try {
|
||||
animate.value?.finish();
|
||||
syncPause();
|
||||
}
|
||||
catch (error) {
|
||||
onError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function cancel(): void {
|
||||
try {
|
||||
animate.value?.cancel();
|
||||
syncPause();
|
||||
}
|
||||
catch (error) {
|
||||
onError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Sync the reactive store from the live Animation on every frame. The loop is
|
||||
// paused by default and only resumed while the animation is actually playing,
|
||||
// so an idle (or finished) animation incurs zero per-frame cost.
|
||||
const { resume: resumeRaf, pause: pauseRaf } = useRafFn(() => {
|
||||
const a = animate.value;
|
||||
if (!a)
|
||||
return;
|
||||
|
||||
store.pending = a.pending;
|
||||
store.playState = a.playState;
|
||||
store.replaceState = a.replaceState;
|
||||
store.startTime = a.startTime;
|
||||
store.currentTime = a.currentTime;
|
||||
store.timeline = a.timeline;
|
||||
store.playbackRate = a.playbackRate;
|
||||
}, { immediate: false, window });
|
||||
|
||||
function syncResume(): void {
|
||||
if (isSupported.value)
|
||||
resumeRaf();
|
||||
}
|
||||
|
||||
function syncPause(): void {
|
||||
// Defer the stop by one frame so the final state (e.g. 'finished') is captured
|
||||
// before the loop halts.
|
||||
if (isSupported.value && window)
|
||||
window.requestAnimationFrame(pauseRaf);
|
||||
}
|
||||
|
||||
watch(() => unrefElement(target), (el) => {
|
||||
if (el)
|
||||
update(true);
|
||||
else
|
||||
animate.value = undefined;
|
||||
});
|
||||
|
||||
watch(() => keyframes, (value) => {
|
||||
if (!animate.value)
|
||||
return;
|
||||
|
||||
update();
|
||||
|
||||
const el = unrefElement(target);
|
||||
if (el && typeof KeyframeEffect !== 'undefined')
|
||||
animate.value.effect = new KeyframeEffect(el as HTMLElement, toValue(value), animateOptions);
|
||||
}, { deep: true });
|
||||
|
||||
const listenerOptions = { passive: true };
|
||||
useEventListener(animate, ['cancel', 'finish', 'remove'], syncPause, listenerOptions);
|
||||
useEventListener(animate, 'finish', () => {
|
||||
if (commitStyles)
|
||||
animate.value?.commitStyles();
|
||||
}, listenerOptions);
|
||||
|
||||
tryOnMounted(() => update(true), { sync: false });
|
||||
|
||||
tryOnScopeDispose(cancel);
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
animate,
|
||||
|
||||
play,
|
||||
pause,
|
||||
reverse,
|
||||
finish,
|
||||
cancel,
|
||||
|
||||
pending,
|
||||
playState,
|
||||
replaceState,
|
||||
startTime,
|
||||
currentTime,
|
||||
timeline,
|
||||
playbackRate,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, isReadonly, ref } from 'vue';
|
||||
import { useCountdown } from '.';
|
||||
|
||||
describe(useCountdown, () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('does not start until start/resume is called (immediate defaults to false)', () => {
|
||||
const { remaining } = useCountdown(5);
|
||||
|
||||
expect(remaining.value).toBe(5);
|
||||
vi.advanceTimersByTime(3000);
|
||||
expect(remaining.value).toBe(5);
|
||||
});
|
||||
|
||||
it('decrements remaining on each tick', () => {
|
||||
const { remaining, start } = useCountdown(5);
|
||||
|
||||
start();
|
||||
expect(remaining.value).toBe(5);
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(remaining.value).toBe(4);
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(remaining.value).toBe(2);
|
||||
});
|
||||
|
||||
it('starts immediately when immediate is true', () => {
|
||||
const { remaining } = useCountdown(3, { immediate: true });
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(remaining.value).toBe(2);
|
||||
});
|
||||
|
||||
it('exposes a read-only remaining ref', () => {
|
||||
const { remaining } = useCountdown(5);
|
||||
expect(isReadonly(remaining)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('stops at zero and never goes negative', () => {
|
||||
const { remaining, start } = useCountdown(2);
|
||||
|
||||
start();
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(remaining.value).toBe(0);
|
||||
});
|
||||
|
||||
it('calls onComplete exactly once when reaching zero', () => {
|
||||
const onComplete = vi.fn();
|
||||
const { start } = useCountdown(2, { onComplete });
|
||||
|
||||
start();
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(onComplete).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(onComplete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onTick with the current remaining value', () => {
|
||||
const onTick = vi.fn();
|
||||
const { start } = useCountdown(3, { onTick });
|
||||
|
||||
start();
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(onTick).toHaveBeenLastCalledWith(2);
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(onTick).toHaveBeenLastCalledWith(1);
|
||||
});
|
||||
|
||||
it('pauses and resumes from where it left off', () => {
|
||||
const { remaining, start, pause, resume, isActive } = useCountdown(10);
|
||||
|
||||
start();
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(remaining.value).toBe(8);
|
||||
|
||||
pause();
|
||||
expect(isActive.value).toBeFalsy();
|
||||
vi.advanceTimersByTime(3000);
|
||||
expect(remaining.value).toBe(8);
|
||||
|
||||
resume();
|
||||
expect(isActive.value).toBeTruthy();
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(remaining.value).toBe(6);
|
||||
});
|
||||
|
||||
it('does not resume when remaining is already zero', () => {
|
||||
const { remaining, start, resume, isActive } = useCountdown(1);
|
||||
|
||||
start();
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(remaining.value).toBe(0);
|
||||
expect(isActive.value).toBeFalsy();
|
||||
|
||||
resume();
|
||||
expect(isActive.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('stop pauses and resets remaining to the initial value', () => {
|
||||
const { remaining, start, stop, isActive } = useCountdown(5);
|
||||
|
||||
start();
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(remaining.value).toBe(3);
|
||||
|
||||
stop();
|
||||
expect(isActive.value).toBeFalsy();
|
||||
expect(remaining.value).toBe(5);
|
||||
});
|
||||
|
||||
it('reset restores the initial value without changing the running state', () => {
|
||||
const { remaining, start, reset, isActive } = useCountdown(5);
|
||||
|
||||
start();
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(remaining.value).toBe(3);
|
||||
|
||||
reset();
|
||||
expect(remaining.value).toBe(5);
|
||||
expect(isActive.value).toBeTruthy();
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(remaining.value).toBe(4);
|
||||
});
|
||||
|
||||
it('reset and start accept an explicit countdown override', () => {
|
||||
const { remaining, start, reset } = useCountdown(5);
|
||||
|
||||
reset(8);
|
||||
expect(remaining.value).toBe(8);
|
||||
|
||||
start(3);
|
||||
expect(remaining.value).toBe(3);
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(remaining.value).toBe(2);
|
||||
});
|
||||
|
||||
it('honours a custom tick interval', () => {
|
||||
const { remaining, start } = useCountdown(5, { interval: 500 });
|
||||
|
||||
start();
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(remaining.value).toBe(4);
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(remaining.value).toBe(2);
|
||||
});
|
||||
|
||||
it('resolves a reactive initial countdown', () => {
|
||||
const initial = ref(4);
|
||||
const { remaining, reset } = useCountdown(initial);
|
||||
|
||||
expect(remaining.value).toBe(4);
|
||||
|
||||
initial.value = 9;
|
||||
reset();
|
||||
expect(remaining.value).toBe(9);
|
||||
});
|
||||
|
||||
it('toggle flips the active state', () => {
|
||||
const { start, toggle, isActive } = useCountdown(10);
|
||||
|
||||
start();
|
||||
expect(isActive.value).toBeTruthy();
|
||||
toggle();
|
||||
expect(isActive.value).toBeFalsy();
|
||||
toggle();
|
||||
expect(isActive.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('cleans up the interval when the scope is disposed', () => {
|
||||
const scope = effectScope();
|
||||
let api: ReturnType<typeof useCountdown> | undefined;
|
||||
|
||||
scope.run(() => {
|
||||
api = useCountdown(10, { immediate: true });
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(api!.remaining.value).toBe(8);
|
||||
|
||||
scope.stop();
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(api!.remaining.value).toBe(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { shallowReadonly, shallowRef, toValue } from 'vue';
|
||||
import type { MaybeRefOrGetter, ShallowRef } from 'vue';
|
||||
import type { ResumableActions } from '@/types';
|
||||
import { useIntervalFn } from '@/composables/animation/useIntervalFn';
|
||||
import type { UseIntervalFnReturn } from '@/composables/animation/useIntervalFn';
|
||||
|
||||
export interface UseCountdownOptions {
|
||||
/**
|
||||
* Tick interval in milliseconds. Each tick decrements `remaining` by one.
|
||||
*
|
||||
* @default 1000
|
||||
*/
|
||||
interval?: MaybeRefOrGetter<number>;
|
||||
|
||||
/**
|
||||
* Start the countdown immediately when the composable is created
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
immediate?: boolean;
|
||||
|
||||
/**
|
||||
* Callback invoked on every tick with the current remaining value
|
||||
*/
|
||||
onTick?: (remaining: number) => void;
|
||||
|
||||
/**
|
||||
* Callback invoked once when the countdown reaches zero
|
||||
*/
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
export interface UseCountdownReturn extends ResumableActions {
|
||||
/**
|
||||
* The remaining seconds, read-only (use `reset`/`start` to change it)
|
||||
*/
|
||||
remaining: Readonly<ShallowRef<number>>;
|
||||
|
||||
/**
|
||||
* Whether the countdown is currently running
|
||||
*/
|
||||
isActive: UseIntervalFnReturn['isActive'];
|
||||
|
||||
/**
|
||||
* Reset `remaining` (defaults to the initial value) without changing the
|
||||
* running state
|
||||
*/
|
||||
reset: (countdown?: MaybeRefOrGetter<number>) => void;
|
||||
|
||||
/**
|
||||
* Pause the countdown and reset `remaining` to the initial value
|
||||
*/
|
||||
stop: () => void;
|
||||
|
||||
/**
|
||||
* Reset `remaining` (defaults to the initial value) and start counting down
|
||||
*/
|
||||
start: (countdown?: MaybeRefOrGetter<number>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useCountdown
|
||||
* @category Animation
|
||||
* @description Reactive countdown timer exposing the remaining seconds plus
|
||||
* `start`/`stop`/`pause`/`resume`/`reset` controls and `onTick`/`onComplete`
|
||||
* callbacks. Built on `useIntervalFn`, so it is SSR-safe and cleans up on scope
|
||||
* dispose.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<number>} initialCountdown The starting value, in seconds (can be reactive)
|
||||
* @param {UseCountdownOptions} [options={}] Options
|
||||
* @returns {UseCountdownReturn} The reactive remaining value and countdown controls
|
||||
*
|
||||
* @example
|
||||
* const { remaining, start, pause, resume, stop } = useCountdown(60);
|
||||
* start();
|
||||
*
|
||||
* @example
|
||||
* useCountdown(10, {
|
||||
* immediate: true,
|
||||
* onTick: (n) => console.log(n),
|
||||
* onComplete: () => console.log('done'),
|
||||
* });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useCountdown(
|
||||
initialCountdown: MaybeRefOrGetter<number>,
|
||||
options: UseCountdownOptions = {},
|
||||
): UseCountdownReturn {
|
||||
const {
|
||||
interval = 1000,
|
||||
immediate = false,
|
||||
onTick,
|
||||
onComplete,
|
||||
} = options;
|
||||
|
||||
const remaining = shallowRef(toValue(initialCountdown));
|
||||
|
||||
const controls = useIntervalFn(() => {
|
||||
const next = remaining.value - 1;
|
||||
remaining.value = next < 0 ? 0 : next;
|
||||
|
||||
onTick?.(remaining.value);
|
||||
|
||||
if (remaining.value <= 0) {
|
||||
controls.pause();
|
||||
onComplete?.();
|
||||
}
|
||||
}, interval, { immediate });
|
||||
|
||||
const reset = (countdown?: MaybeRefOrGetter<number>): void => {
|
||||
remaining.value = toValue(countdown) ?? toValue(initialCountdown);
|
||||
};
|
||||
|
||||
const stop = (): void => {
|
||||
controls.pause();
|
||||
reset();
|
||||
};
|
||||
|
||||
const resume = (): void => {
|
||||
if (!controls.isActive.value && remaining.value > 0)
|
||||
controls.resume();
|
||||
};
|
||||
|
||||
const start = (countdown?: MaybeRefOrGetter<number>): void => {
|
||||
reset(countdown);
|
||||
controls.resume();
|
||||
};
|
||||
|
||||
const toggle = (): void => {
|
||||
if (controls.isActive.value)
|
||||
controls.pause();
|
||||
else
|
||||
resume();
|
||||
};
|
||||
|
||||
return {
|
||||
remaining: shallowReadonly(remaining),
|
||||
isActive: controls.isActive,
|
||||
reset,
|
||||
stop,
|
||||
start,
|
||||
pause: controls.pause,
|
||||
resume,
|
||||
toggle,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { effectScope, ref } from 'vue';
|
||||
import { formatDate, normalizeDate, useDateFormat } from '.';
|
||||
|
||||
// A fixed local date: 2024-03-09 18:07:05.042 (a Saturday).
|
||||
function fixture(): Date {
|
||||
return new Date(2024, 2, 9, 18, 7, 5, 42);
|
||||
}
|
||||
|
||||
describe(useDateFormat, () => {
|
||||
it('defaults to HH:mm:ss', () => {
|
||||
const formatted = useDateFormat(fixture());
|
||||
expect(formatted.value).toBe('18:07:05');
|
||||
});
|
||||
|
||||
it('formats year/month/day tokens', () => {
|
||||
const date = fixture();
|
||||
expect(useDateFormat(date, 'YYYY-MM-DD').value).toBe('2024-03-09');
|
||||
expect(useDateFormat(date, 'YY/M/D').value).toBe('24/3/9');
|
||||
});
|
||||
|
||||
it('formats time tokens including milliseconds and 12-hour', () => {
|
||||
const date = fixture();
|
||||
expect(useDateFormat(date, 'HH:mm:ss.SSS').value).toBe('18:07:05.042');
|
||||
expect(useDateFormat(date, 'h:mm').value).toBe('6:07');
|
||||
expect(useDateFormat(date, 'hh').value).toBe('06');
|
||||
});
|
||||
|
||||
it('handles 12 -> 12 and 0 -> 12 for the h token', () => {
|
||||
expect(useDateFormat(new Date(2024, 0, 1, 0, 0, 0), 'h A').value).toBe('12 AM');
|
||||
expect(useDateFormat(new Date(2024, 0, 1, 12, 0, 0), 'h A').value).toBe('12 PM');
|
||||
});
|
||||
|
||||
it('formats the meridiem variants', () => {
|
||||
const pm = fixture();
|
||||
expect(useDateFormat(pm, 'A').value).toBe('PM');
|
||||
expect(useDateFormat(pm, 'AA').value).toBe('P.M.');
|
||||
expect(useDateFormat(pm, 'a').value).toBe('pm');
|
||||
expect(useDateFormat(pm, 'aa').value).toBe('p.m.');
|
||||
|
||||
const am = new Date(2024, 0, 1, 6, 0, 0);
|
||||
expect(useDateFormat(am, 'A').value).toBe('AM');
|
||||
});
|
||||
|
||||
it('formats ordinal tokens', () => {
|
||||
const date = new Date(2024, 0, 1, 3, 0, 0); // Jan 1st, 3 o'clock
|
||||
expect(useDateFormat(date, 'Do').value).toBe('1st');
|
||||
expect(useDateFormat(date, 'Mo').value).toBe('1st');
|
||||
expect(useDateFormat(new Date(2024, 1, 22), 'Do').value).toBe('22nd');
|
||||
expect(useDateFormat(new Date(2024, 1, 23), 'Do').value).toBe('23rd');
|
||||
expect(useDateFormat(new Date(2024, 1, 11), 'Do').value).toBe('11th');
|
||||
});
|
||||
|
||||
it('formats localized weekday and month with the locales option', () => {
|
||||
const date = fixture(); // a Saturday in March
|
||||
expect(useDateFormat(date, 'dddd', { locales: 'en-US' }).value).toBe('Saturday');
|
||||
expect(useDateFormat(date, 'ddd', { locales: 'en-US' }).value).toBe('Sat');
|
||||
expect(useDateFormat(date, 'MMMM', { locales: 'en-US' }).value).toBe('March');
|
||||
expect(useDateFormat(date, 'MMM', { locales: 'en-US' }).value).toBe('Mar');
|
||||
expect(useDateFormat(date, 'd', { locales: 'en-US' }).value).toBe('6'); // Saturday
|
||||
});
|
||||
|
||||
it('uses a custom meridiem function', () => {
|
||||
const date = fixture();
|
||||
const formatted = useDateFormat(date, 'h:mm a', {
|
||||
customMeridiem: hours => (hours < 12 ? 'morning' : 'evening'),
|
||||
});
|
||||
expect(formatted.value).toBe('6:07 evening');
|
||||
});
|
||||
|
||||
it('emits [literal] escapes verbatim', () => {
|
||||
const date = fixture();
|
||||
expect(useDateFormat(date, '[Year:] YYYY').value).toBe('Year: 2024');
|
||||
expect(useDateFormat(date, '[YYYY] YYYY').value).toBe('YYYY 2024');
|
||||
});
|
||||
|
||||
it('is reactive to the date, format, and locale', () => {
|
||||
const date = ref<Date>(fixture());
|
||||
const format = ref('YYYY');
|
||||
const locale = ref('en-US');
|
||||
const formatted = useDateFormat(date, format, { locales: locale });
|
||||
|
||||
expect(formatted.value).toBe('2024');
|
||||
|
||||
date.value = new Date(2025, 0, 1);
|
||||
expect(formatted.value).toBe('2025');
|
||||
|
||||
format.value = 'MMMM';
|
||||
expect(formatted.value).toBe('January');
|
||||
|
||||
locale.value = 'fr-FR';
|
||||
expect(formatted.value).toBe('janvier');
|
||||
});
|
||||
|
||||
it('accepts a numeric timestamp and a getter', () => {
|
||||
const date = fixture();
|
||||
expect(useDateFormat(date.getTime(), 'YYYY-MM-DD').value).toBe('2024-03-09');
|
||||
expect(useDateFormat(() => date, 'YYYY').value).toBe('2024');
|
||||
});
|
||||
|
||||
it('parses loose date strings without a trailing Z', () => {
|
||||
expect(useDateFormat('2024-03-09', 'YYYY-MM-DD').value).toBe('2024-03-09');
|
||||
expect(useDateFormat('2024-3', 'YYYY-MM-DD').value).toBe('2024-03-01');
|
||||
expect(useDateFormat('2024-03-09 18:07:05', 'HH:mm:ss').value).toBe('18:07:05');
|
||||
});
|
||||
|
||||
it('handles null/undefined by resolving to now without throwing', () => {
|
||||
expect(useDateFormat(undefined, 'YYYY').value).toMatch(/^\d{4}$/);
|
||||
expect(useDateFormat(null, 'YYYY').value).toMatch(/^\d{4}$/);
|
||||
});
|
||||
|
||||
it('returns "Invalid Date" for unparseable input instead of NaN tokens', () => {
|
||||
expect(useDateFormat('not a date', 'YYYY-MM-DD').value).toBe('Invalid Date');
|
||||
expect(useDateFormat(Number.NaN, 'HH:mm:ss').value).toBe('Invalid Date');
|
||||
});
|
||||
|
||||
it('constructs inside an effect scope without throwing (SSR-safe, no global access)', () => {
|
||||
const scope = effectScope();
|
||||
let formatted: ReturnType<typeof useDateFormat> | undefined;
|
||||
|
||||
scope.run(() => {
|
||||
formatted = useDateFormat(fixture(), 'YYYY-MM-DD');
|
||||
});
|
||||
|
||||
expect(formatted?.value).toBe('2024-03-09');
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe(formatDate, () => {
|
||||
it('formats a date one-shot', () => {
|
||||
expect(formatDate(fixture(), 'YYYY/MM/DD')).toBe('2024/03/09');
|
||||
});
|
||||
|
||||
it('returns "Invalid Date" for an invalid date', () => {
|
||||
expect(formatDate(new Date(Number.NaN), 'YYYY')).toBe('Invalid Date');
|
||||
});
|
||||
});
|
||||
|
||||
describe(normalizeDate, () => {
|
||||
it('returns a fresh Date for a Date input', () => {
|
||||
const date = fixture();
|
||||
const normalized = normalizeDate(date);
|
||||
expect(normalized).not.toBe(date);
|
||||
expect(normalized.getTime()).toBe(date.getTime());
|
||||
});
|
||||
|
||||
it('resolves null/undefined to a valid current Date', () => {
|
||||
expect(Number.isNaN(normalizeDate(undefined).getTime())).toBeFalsy();
|
||||
expect(Number.isNaN(normalizeDate(null).getTime())).toBeFalsy();
|
||||
});
|
||||
|
||||
it('parses a numeric timestamp', () => {
|
||||
const date = fixture();
|
||||
expect(normalizeDate(date.getTime()).getTime()).toBe(date.getTime());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import { computed, toValue } from 'vue';
|
||||
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
|
||||
import { isDate, isString } from '@robonen/stdlib';
|
||||
|
||||
/**
|
||||
* Accepted input for {@link useDateFormat}: a `Date`, a millisecond timestamp,
|
||||
* a parseable date string, or `null`/`undefined` (resolves to "now").
|
||||
*/
|
||||
export type DateLike = Date | number | string | null | undefined;
|
||||
|
||||
/**
|
||||
* Signature for a custom meridiem (AM/PM) formatter.
|
||||
*
|
||||
* @param hours The hour of the day, 0-23
|
||||
* @param minutes The minute of the hour, 0-59
|
||||
* @param isLowercase Whether the token requested a lowercase form (`a`/`aa`)
|
||||
* @param hasPeriod Whether the token requested period separators (`AA`/`aa`)
|
||||
*/
|
||||
export type CustomMeridiem
|
||||
= (hours: number, minutes: number, isLowercase?: boolean, hasPeriod?: boolean) => string;
|
||||
|
||||
export interface UseDateFormatOptions {
|
||||
/**
|
||||
* The locale(s) used for the `dd`/`ddd`/`dddd`/`MMM`/`MMMM`/`z` tokens.
|
||||
*
|
||||
* Accepts a reactive value (ref or getter); the output recomputes when it
|
||||
* changes.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl#locales_argument
|
||||
*/
|
||||
locales?: MaybeRefOrGetter<Intl.LocalesArgument>;
|
||||
|
||||
/**
|
||||
* A custom function controlling how the meridiem (`A`/`AA`/`a`/`aa`) is
|
||||
* rendered.
|
||||
*/
|
||||
customMeridiem?: CustomMeridiem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive formatted date string.
|
||||
*/
|
||||
export type UseDateFormatReturn = ComputedRef<string>;
|
||||
|
||||
// Matches a token, or a `[literal]` escape that is emitted verbatim.
|
||||
const REGEX_FORMAT
|
||||
= /* #__PURE__ */ /[YMDHhms]o|\[([^\]]+)\]|Y{1,4}|M{1,4}|D{1,2}|d{1,4}|H{1,2}|h{1,2}|a{1,2}|A{1,2}|m{1,2}|s{1,2}|z{1,4}|SSS/g;
|
||||
|
||||
// Loose ISO-ish parser used for date strings without a trailing `Z`. The optional
|
||||
// separators make adjacent digit groups technically "misleading" to the linter,
|
||||
// but this is the deliberate lenient dayjs parser (accepts `2024-01-01` and
|
||||
// `20240101`); JS lacks possessive quantifiers to disambiguate it.
|
||||
// eslint-disable-next-line regexp/no-misleading-capturing-group
|
||||
const REGEX_PARSE = /* #__PURE__ */ /^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[T\s]*(\d{1,2})?:?(\d{1,2})?:?(\d{1,2})?[.:]?(\d+)?$/i;
|
||||
|
||||
const ORDINAL_SUFFIXES = ['th', 'st', 'nd', 'rd'] as const;
|
||||
|
||||
function defaultMeridiem(
|
||||
hours: number,
|
||||
_minutes: number,
|
||||
isLowercase?: boolean,
|
||||
hasPeriod?: boolean,
|
||||
): string {
|
||||
let m = hours < 12 ? 'AM' : 'PM';
|
||||
if (hasPeriod) m = `${m[0]}.${m[1]}.`;
|
||||
return isLowercase ? m.toLowerCase() : m;
|
||||
}
|
||||
|
||||
function formatOrdinal(num: number): string {
|
||||
const v = num % 100;
|
||||
return num + (ORDINAL_SUFFIXES[(v - 20) % 10] || ORDINAL_SUFFIXES[v] || ORDINAL_SUFFIXES[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce a {@link DateLike} into a `Date`. `null`/`undefined` become the
|
||||
* current time; a non-UTC string is parsed leniently so partial dates such as
|
||||
* `'2024-3'` are accepted.
|
||||
*
|
||||
* @param date The value to coerce
|
||||
* @returns A `Date` instance (possibly `Invalid Date`)
|
||||
*/
|
||||
export function normalizeDate(date: DateLike): Date {
|
||||
if (date === null || date === undefined) return new Date();
|
||||
if (isDate(date)) return new Date(date.getTime());
|
||||
if (isString(date) && !/z$/i.test(date)) {
|
||||
const d = REGEX_PARSE.exec(date);
|
||||
if (d) {
|
||||
const month = d[2] ? Number(d[2]) - 1 : 0;
|
||||
const ms = (d[7] || '0').slice(0, 3);
|
||||
return new Date(
|
||||
Number(d[1]),
|
||||
month,
|
||||
Number(d[3]) || 1,
|
||||
Number(d[4]) || 0,
|
||||
Number(d[5]) || 0,
|
||||
Number(d[6]) || 0,
|
||||
Number(ms),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return new Date(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a `Date` against a token string. Exposed for one-shot, non-reactive
|
||||
* formatting; {@link useDateFormat} wraps this in a `computed`.
|
||||
*
|
||||
* @param date The date to format
|
||||
* @param formatStr The combination of tokens (e.g. `'YYYY-MM-DD HH:mm:ss'`)
|
||||
* @param options Locale and meridiem options
|
||||
* @returns The formatted string
|
||||
*/
|
||||
export function formatDate(
|
||||
date: Date,
|
||||
formatStr: string,
|
||||
options: UseDateFormatOptions = {},
|
||||
): string {
|
||||
// Invalid dates round-trip to the literal "Invalid Date" rather than
|
||||
// emitting `NaN` for every numeric token.
|
||||
if (Number.isNaN(date.getTime())) return 'Invalid Date';
|
||||
|
||||
const years = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
const days = date.getDate();
|
||||
const hours = date.getHours();
|
||||
const minutes = date.getMinutes();
|
||||
const seconds = date.getSeconds();
|
||||
const milliseconds = date.getMilliseconds();
|
||||
const day = date.getDay();
|
||||
const hour12 = hours % 12 || 12;
|
||||
|
||||
const locales = toValue(options.locales);
|
||||
const meridiem = options.customMeridiem ?? defaultMeridiem;
|
||||
// The timeZoneName lands after the date in the localized string; grab it.
|
||||
const offsetName = (style: 'shortOffset' | 'longOffset'): string =>
|
||||
date.toLocaleDateString(locales, { timeZoneName: style }).split(' ')[1] ?? '';
|
||||
|
||||
const matches: Record<string, () => string | number> = {
|
||||
Yo: () => formatOrdinal(years),
|
||||
YY: () => String(years).slice(-2),
|
||||
YYYY: () => years,
|
||||
M: () => month + 1,
|
||||
Mo: () => formatOrdinal(month + 1),
|
||||
MM: () => String(month + 1).padStart(2, '0'),
|
||||
MMM: () => date.toLocaleDateString(locales, { month: 'short' }),
|
||||
MMMM: () => date.toLocaleDateString(locales, { month: 'long' }),
|
||||
D: () => String(days),
|
||||
Do: () => formatOrdinal(days),
|
||||
DD: () => String(days).padStart(2, '0'),
|
||||
H: () => String(hours),
|
||||
Ho: () => formatOrdinal(hours),
|
||||
HH: () => String(hours).padStart(2, '0'),
|
||||
h: () => String(hour12),
|
||||
ho: () => formatOrdinal(hour12),
|
||||
hh: () => String(hour12).padStart(2, '0'),
|
||||
m: () => String(minutes),
|
||||
mo: () => formatOrdinal(minutes),
|
||||
mm: () => String(minutes).padStart(2, '0'),
|
||||
s: () => String(seconds),
|
||||
so: () => formatOrdinal(seconds),
|
||||
ss: () => String(seconds).padStart(2, '0'),
|
||||
SSS: () => String(milliseconds).padStart(3, '0'),
|
||||
d: () => day,
|
||||
dd: () => date.toLocaleDateString(locales, { weekday: 'narrow' }),
|
||||
ddd: () => date.toLocaleDateString(locales, { weekday: 'short' }),
|
||||
dddd: () => date.toLocaleDateString(locales, { weekday: 'long' }),
|
||||
A: () => meridiem(hours, minutes),
|
||||
AA: () => meridiem(hours, minutes, false, true),
|
||||
a: () => meridiem(hours, minutes, true),
|
||||
aa: () => meridiem(hours, minutes, true, true),
|
||||
z: () => offsetName('shortOffset'),
|
||||
zz: () => offsetName('shortOffset'),
|
||||
zzz: () => offsetName('shortOffset'),
|
||||
zzzz: () => offsetName('longOffset'),
|
||||
};
|
||||
|
||||
return formatStr.replaceAll(REGEX_FORMAT, (match, literal) =>
|
||||
literal ?? String(matches[match]?.() ?? match),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useDateFormat
|
||||
* @category Animation
|
||||
* @description Reactively format a `Date`, timestamp, or date string against a
|
||||
* token string (`YYYY MM DD HH mm ss SSS dddd A` etc.). Recomputes when the
|
||||
* date, format, or locale changes.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<DateLike>} date The date to format
|
||||
* @param {MaybeRefOrGetter<string>} [formatStr='HH:mm:ss'] The token string
|
||||
* @param {UseDateFormatOptions} [options={}] Locale and meridiem options
|
||||
* @returns {ComputedRef<string>} The reactive formatted string
|
||||
*
|
||||
* @example
|
||||
* const formatted = useDateFormat(useNow(), 'YYYY-MM-DD HH:mm:ss');
|
||||
*
|
||||
* @example
|
||||
* // Localized weekday + month, reactive locale
|
||||
* const locale = ref('fr-FR');
|
||||
* const label = useDateFormat(date, 'dddd, MMMM D', { locales: locale });
|
||||
*
|
||||
* @example
|
||||
* // Custom meridiem
|
||||
* const t = useDateFormat(date, 'hh:mm a', {
|
||||
* customMeridiem: (h) => (h < 12 ? 'morning' : 'evening'),
|
||||
* });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useDateFormat(
|
||||
date: MaybeRefOrGetter<DateLike>,
|
||||
formatStr: MaybeRefOrGetter<string> = 'HH:mm:ss',
|
||||
options: UseDateFormatOptions = {},
|
||||
): UseDateFormatReturn {
|
||||
return computed(() => formatDate(normalizeDate(toValue(date)), toValue(formatStr), options));
|
||||
}
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
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';
|
||||
import { useIntervalFn } from '@/composables/animation/useIntervalFn';
|
||||
import type { UseIntervalFnReturn } from '@/composables/animation/useIntervalFn';
|
||||
|
||||
export interface UseIntervalOptions<Controls extends boolean> {
|
||||
/**
|
||||
@@ -46,7 +46,7 @@ export type UseIntervalReturn = Readonly<ShallowRef<number>> | UseIntervalContro
|
||||
|
||||
/**
|
||||
* @name useInterval
|
||||
* @category Utilities
|
||||
* @category Animation
|
||||
* @description Reactive counter that increments on every interval tick.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<number>} [interval=1000] Interval in milliseconds (can be reactive)
|
||||
@@ -0,0 +1,140 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import { useNow } from '.';
|
||||
import type { UseNowControls } from '.';
|
||||
|
||||
describe(useNow, () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1000);
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('returns the current date', () => {
|
||||
const now = useNow({ interval: 100 });
|
||||
expect(now.value).toBeInstanceOf(Date);
|
||||
expect(now.value.getTime()).toBe(1000);
|
||||
});
|
||||
|
||||
it('updates on the interval', () => {
|
||||
const now = useNow({ interval: 100 });
|
||||
|
||||
// advanceTimersByTime also advances the mocked clock, so the tick fires at 1100
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(now.value.getTime()).toBe(1100);
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(now.value.getTime()).toBe(1200);
|
||||
});
|
||||
|
||||
it('exposes controls when controls: true', () => {
|
||||
const { now, pause } = useNow({ controls: true, interval: 100 });
|
||||
|
||||
expect(now.value).toBeInstanceOf(Date);
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(now.value.getTime()).toBe(1100);
|
||||
|
||||
pause();
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(now.value.getTime()).toBe(1100);
|
||||
});
|
||||
|
||||
it('exposes isActive and reflects pause/resume/toggle', () => {
|
||||
const { isActive, pause, resume, toggle } = useNow({ controls: true, interval: 100 });
|
||||
|
||||
expect(isActive.value).toBeTruthy();
|
||||
|
||||
pause();
|
||||
expect(isActive.value).toBeFalsy();
|
||||
|
||||
resume();
|
||||
expect(isActive.value).toBeTruthy();
|
||||
|
||||
toggle();
|
||||
expect(isActive.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('does not start updating when immediate is false', () => {
|
||||
const { now, isActive } = useNow({ controls: true, interval: 100, immediate: false });
|
||||
|
||||
expect(isActive.value).toBeFalsy();
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(now.value.getTime()).toBe(1000);
|
||||
});
|
||||
|
||||
it('invokes the callback on every update with the current date', () => {
|
||||
const callback = vi.fn();
|
||||
useNow({ interval: 100, callback });
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(callback.mock.lastCall?.[0]).toBeInstanceOf(Date);
|
||||
expect((callback.mock.lastCall?.[0] as Date).getTime()).toBe(1100);
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
expect((callback.mock.lastCall?.[0] as Date).getTime()).toBe(1200);
|
||||
});
|
||||
|
||||
it('produces a fresh Date instance on each update', () => {
|
||||
const now = useNow({ interval: 100 });
|
||||
const first = now.value;
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(now.value).not.toBe(first);
|
||||
expect(now.value.getTime()).toBe(1100);
|
||||
});
|
||||
|
||||
it('defaults to the requestAnimationFrame strategy', () => {
|
||||
const raf = vi.fn().mockReturnValue(1);
|
||||
const caf = vi.fn();
|
||||
vi.stubGlobal('requestAnimationFrame', raf);
|
||||
vi.stubGlobal('cancelAnimationFrame', caf);
|
||||
|
||||
try {
|
||||
const scope = effectScope();
|
||||
let result: UseNowControls | undefined;
|
||||
|
||||
scope.run(() => {
|
||||
result = useNow({ controls: true });
|
||||
});
|
||||
|
||||
// RAF strategy starts the loop immediately
|
||||
expect(result?.isActive.value).toBeTruthy();
|
||||
expect(raf).toHaveBeenCalled();
|
||||
|
||||
scope.stop();
|
||||
}
|
||||
finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('cleans up the updater when the scope is disposed', () => {
|
||||
const scope = effectScope();
|
||||
let now: Ref<Date> | undefined;
|
||||
|
||||
scope.run(() => {
|
||||
now = useNow({ interval: 100 });
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(now?.value.getTime()).toBe(1100);
|
||||
|
||||
scope.stop();
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(now?.value.getTime()).toBe(1100);
|
||||
});
|
||||
|
||||
it('does not update when interval mode runs without a callback firing (SSR-safe construction)', () => {
|
||||
// useNow must construct without throwing even before any tick; the initial
|
||||
// value is always a valid Date regardless of environment.
|
||||
const now = useNow({ interval: 100, immediate: false });
|
||||
expect(now.value).toBeInstanceOf(Date);
|
||||
expect(now.value.getTime()).toBe(1000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { shallowRef } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import type { ResumableActions } from '@/types';
|
||||
import { useRafFn } from '@/composables/animation/useRafFn';
|
||||
import { useIntervalFn } from '@/composables/animation/useIntervalFn';
|
||||
|
||||
export interface UseNowOptions<Controls extends boolean> {
|
||||
/**
|
||||
* Expose pause/resume controls alongside the date
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
controls?: Controls;
|
||||
|
||||
/**
|
||||
* 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 date
|
||||
*/
|
||||
callback?: (now: Date) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause/resume controls returned when `controls: true`.
|
||||
*/
|
||||
export interface UseNowControls extends ResumableActions {
|
||||
/**
|
||||
* The reactive current date
|
||||
*/
|
||||
now: Ref<Date>;
|
||||
|
||||
/**
|
||||
* Whether the updater (RAF loop or interval) is currently active
|
||||
*/
|
||||
isActive: Readonly<Ref<boolean>>;
|
||||
}
|
||||
|
||||
export type UseNowReturn<Controls extends boolean>
|
||||
= Controls extends true ? UseNowControls : Ref<Date>;
|
||||
|
||||
/**
|
||||
* @name useNow
|
||||
* @category Animation
|
||||
* @description Reactive current `Date`, updated via `requestAnimationFrame`
|
||||
* or a fixed interval.
|
||||
*
|
||||
* @param {UseNowOptions} [options={}] Options
|
||||
* @returns {Ref<Date> | UseNowControls} The date, or controls when `controls: true`
|
||||
*
|
||||
* @example
|
||||
* const now = useNow();
|
||||
*
|
||||
* @example
|
||||
* const { now, pause, resume, isActive } = useNow({ controls: true, interval: 1000 });
|
||||
*
|
||||
* @example
|
||||
* // Run a callback on every update
|
||||
* useNow({ interval: 1000, callback: date => console.log(date.toISOString()) });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useNow(options?: UseNowOptions<false>): Ref<Date>;
|
||||
export function useNow(options: UseNowOptions<true>): UseNowControls;
|
||||
export function useNow(
|
||||
options: UseNowOptions<boolean> = {},
|
||||
): Ref<Date> | UseNowControls {
|
||||
const {
|
||||
controls = false,
|
||||
immediate = true,
|
||||
interval = 'requestAnimationFrame',
|
||||
callback,
|
||||
} = options;
|
||||
|
||||
const now = shallowRef(new Date());
|
||||
|
||||
const update = callback
|
||||
? () => {
|
||||
now.value = new Date();
|
||||
callback(now.value);
|
||||
}
|
||||
: () => {
|
||||
now.value = new Date();
|
||||
};
|
||||
|
||||
const resumableControls = interval === 'requestAnimationFrame'
|
||||
? useRafFn(update, { immediate })
|
||||
: useIntervalFn(update, interval, { immediate });
|
||||
|
||||
if (controls) {
|
||||
return {
|
||||
now,
|
||||
...resumableControls,
|
||||
};
|
||||
}
|
||||
|
||||
return now;
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, shallowRef } from 'vue';
|
||||
import type { ComputedRef } from 'vue';
|
||||
import { formatTimeAgo, useTimeAgo } from '.';
|
||||
import type { UseTimeAgoControls, UseTimeAgoMessages } from '.';
|
||||
|
||||
const BASE = 1_700_000_000_000; // fixed epoch for deterministic diffs
|
||||
|
||||
const SECOND = 1000;
|
||||
const MINUTE = 60 * SECOND;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
const WEEK = 7 * DAY;
|
||||
|
||||
describe(formatTimeAgo, () => {
|
||||
it('returns justNow when under a minute by default', () => {
|
||||
expect(formatTimeAgo(new Date(BASE - 30 * SECOND), {}, BASE)).toBe('just now');
|
||||
});
|
||||
|
||||
it('shows seconds when showSecond is true', () => {
|
||||
expect(formatTimeAgo(new Date(BASE - 30 * SECOND), { showSecond: true }, BASE)).toBe('30 seconds ago');
|
||||
});
|
||||
|
||||
it('formats minutes in the past', () => {
|
||||
expect(formatTimeAgo(new Date(BASE - 3 * MINUTE), {}, BASE)).toBe('3 minutes ago');
|
||||
});
|
||||
|
||||
it('formats a single minute (no pluralization)', () => {
|
||||
expect(formatTimeAgo(new Date(BASE - 1 * MINUTE), {}, BASE)).toBe('1 minute ago');
|
||||
});
|
||||
|
||||
it('formats future minutes', () => {
|
||||
expect(formatTimeAgo(new Date(BASE + 5 * MINUTE), {}, BASE)).toBe('in 5 minutes');
|
||||
});
|
||||
|
||||
it('formats hours', () => {
|
||||
expect(formatTimeAgo(new Date(BASE - 2 * HOUR), {}, BASE)).toBe('2 hours ago');
|
||||
});
|
||||
|
||||
it('uses the special yesterday/tomorrow forms for a single day', () => {
|
||||
expect(formatTimeAgo(new Date(BASE - 1 * DAY), {}, BASE)).toBe('yesterday');
|
||||
expect(formatTimeAgo(new Date(BASE + 1 * DAY), {}, BASE)).toBe('tomorrow');
|
||||
});
|
||||
|
||||
it('uses last week / next week for a single week', () => {
|
||||
expect(formatTimeAgo(new Date(BASE - 1 * WEEK), {}, BASE)).toBe('last week');
|
||||
expect(formatTimeAgo(new Date(BASE + 1 * WEEK), {}, BASE)).toBe('next week');
|
||||
});
|
||||
|
||||
it('falls back to the full date when a numeric max is exceeded', () => {
|
||||
const from = new Date(BASE - 10 * DAY);
|
||||
expect(formatTimeAgo(from, { max: 5 * DAY }, BASE)).toBe(from.toISOString().slice(0, 10));
|
||||
});
|
||||
|
||||
it('falls back to the full date when a named-unit max is exceeded', () => {
|
||||
const from = new Date(BASE - 3 * WEEK);
|
||||
expect(formatTimeAgo(from, { max: 'day' }, BASE)).toBe(from.toISOString().slice(0, 10));
|
||||
});
|
||||
|
||||
it('respects a custom fullDateFormatter', () => {
|
||||
const from = new Date(BASE - 10 * DAY);
|
||||
expect(formatTimeAgo(from, { max: 'day', fullDateFormatter: () => 'CUSTOM' }, BASE)).toBe('CUSTOM');
|
||||
});
|
||||
|
||||
it('honors a ceil rounding strategy', () => {
|
||||
// 90 seconds -> 1.5 minutes -> ceil = 2
|
||||
expect(formatTimeAgo(new Date(BASE - 90 * SECOND), { rounding: 'ceil' }, BASE)).toBe('2 minutes ago');
|
||||
});
|
||||
|
||||
it('honors floor rounding', () => {
|
||||
// 119 seconds -> 1.98 minutes -> floor = 1
|
||||
expect(formatTimeAgo(new Date(BASE - 119 * SECOND), { rounding: 'floor' }, BASE)).toBe('1 minute ago');
|
||||
});
|
||||
|
||||
it('honors numeric (decimal-place) rounding', () => {
|
||||
// 90 seconds -> 1.5 minutes, rounded to 1 dp = 1.5
|
||||
expect(formatTimeAgo(new Date(BASE - 90 * SECOND), { rounding: 1 }, BASE)).toBe('1.5 minutes ago');
|
||||
});
|
||||
|
||||
it('returns the invalid message for an unparseable date', () => {
|
||||
expect(formatTimeAgo(new Date('not a date'), {}, BASE)).toBe('');
|
||||
});
|
||||
|
||||
it('supports custom i18n messages', () => {
|
||||
const messages: UseTimeAgoMessages = {
|
||||
justNow: 'à l\'instant',
|
||||
past: n => `il y a ${n}`,
|
||||
future: n => `dans ${n}`,
|
||||
invalid: 'invalide',
|
||||
second: n => `${n} seconde${n > 1 ? 's' : ''}`,
|
||||
minute: n => `${n} minute${n > 1 ? 's' : ''}`,
|
||||
hour: n => `${n} heure${n > 1 ? 's' : ''}`,
|
||||
day: n => `${n} jour${n > 1 ? 's' : ''}`,
|
||||
week: n => `${n} semaine${n > 1 ? 's' : ''}`,
|
||||
month: n => `${n} mois`,
|
||||
year: n => `${n} an${n > 1 ? 's' : ''}`,
|
||||
};
|
||||
|
||||
expect(formatTimeAgo(new Date(BASE - 3 * MINUTE), { messages }, BASE)).toBe('il y a 3 minutes');
|
||||
expect(formatTimeAgo(new Date(BASE + 3 * MINUTE), { messages }, BASE)).toBe('dans 3 minutes');
|
||||
});
|
||||
|
||||
it('supports string-template (i18n) past/future with {0} placeholder', () => {
|
||||
const messages: UseTimeAgoMessages = {
|
||||
justNow: 'now',
|
||||
past: '{0} ago',
|
||||
future: 'in {0}',
|
||||
invalid: '',
|
||||
second: '{0}s',
|
||||
minute: '{0}m',
|
||||
hour: '{0}h',
|
||||
day: '{0}d',
|
||||
week: '{0}w',
|
||||
month: '{0}mo',
|
||||
year: '{0}y',
|
||||
};
|
||||
|
||||
expect(formatTimeAgo(new Date(BASE - 3 * MINUTE), { messages }, BASE)).toBe('3m ago');
|
||||
});
|
||||
});
|
||||
|
||||
describe(useTimeAgo, () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(BASE);
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('returns a computed string by default', () => {
|
||||
const scope = effectScope();
|
||||
let timeAgo: ComputedRef<string> | undefined;
|
||||
|
||||
scope.run(() => {
|
||||
timeAgo = useTimeAgo(new Date(BASE - 3 * MINUTE));
|
||||
});
|
||||
|
||||
expect(timeAgo?.value).toBe('3 minutes ago');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('recomputes as the clock advances on the interval', () => {
|
||||
const scope = effectScope();
|
||||
let timeAgo: ComputedRef<string> | undefined;
|
||||
|
||||
scope.run(() => {
|
||||
timeAgo = useTimeAgo(new Date(BASE - 5 * SECOND), { updateInterval: 1000, showSecond: true });
|
||||
});
|
||||
|
||||
expect(timeAgo?.value).toBe('5 seconds ago');
|
||||
|
||||
vi.advanceTimersByTime(55 * SECOND);
|
||||
expect(timeAgo?.value).toBe('1 minute ago');
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reacts to a changing reactive time source', () => {
|
||||
const scope = effectScope();
|
||||
const time = shallowRef(new Date(BASE - 1 * MINUTE));
|
||||
let timeAgo: ComputedRef<string> | undefined;
|
||||
|
||||
scope.run(() => {
|
||||
timeAgo = useTimeAgo(time);
|
||||
});
|
||||
|
||||
expect(timeAgo?.value).toBe('1 minute ago');
|
||||
|
||||
time.value = new Date(BASE - 2 * HOUR);
|
||||
expect(timeAgo?.value).toBe('2 hours ago');
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('accepts a numeric timestamp and a string date', () => {
|
||||
const scope = effectScope();
|
||||
let fromNumber: ComputedRef<string> | undefined;
|
||||
let fromString: ComputedRef<string> | undefined;
|
||||
|
||||
scope.run(() => {
|
||||
fromNumber = useTimeAgo(BASE - 3 * MINUTE);
|
||||
fromString = useTimeAgo(new Date(BASE - 3 * MINUTE).toISOString());
|
||||
});
|
||||
|
||||
expect(fromNumber?.value).toBe('3 minutes ago');
|
||||
expect(fromString?.value).toBe('3 minutes ago');
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('exposes controls when controls: true', () => {
|
||||
const scope = effectScope();
|
||||
let ctrl: UseTimeAgoControls | undefined;
|
||||
|
||||
scope.run(() => {
|
||||
ctrl = useTimeAgo(new Date(BASE - 5 * SECOND), { controls: true, updateInterval: 1000, showSecond: true });
|
||||
});
|
||||
|
||||
if (!ctrl)
|
||||
throw new Error('controls not created');
|
||||
|
||||
expect(ctrl.timeAgo.value).toBe('5 seconds ago');
|
||||
expect(ctrl.isActive.value).toBeTruthy();
|
||||
|
||||
vi.advanceTimersByTime(55 * SECOND);
|
||||
expect(ctrl.timeAgo.value).toBe('1 minute ago');
|
||||
|
||||
// pausing stops further recomputation
|
||||
ctrl.pause();
|
||||
expect(ctrl.isActive.value).toBeFalsy();
|
||||
|
||||
vi.advanceTimersByTime(60 * SECOND);
|
||||
expect(ctrl.timeAgo.value).toBe('1 minute ago');
|
||||
|
||||
ctrl.resume();
|
||||
expect(ctrl.isActive.value).toBeTruthy();
|
||||
// resume does not fire the callback immediately; the next tick refreshes now
|
||||
vi.advanceTimersByTime(1 * SECOND);
|
||||
expect(ctrl.timeAgo.value).toBe('2 minutes ago');
|
||||
|
||||
ctrl.toggle();
|
||||
expect(ctrl.isActive.value).toBeFalsy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not start ticking when immediate is false', () => {
|
||||
const scope = effectScope();
|
||||
let result: { timeAgo: { value: string }; isActive: { value: boolean } } | undefined;
|
||||
|
||||
scope.run(() => {
|
||||
result = useTimeAgo(new Date(BASE - 5 * SECOND), {
|
||||
controls: true,
|
||||
updateInterval: 1000,
|
||||
immediate: false,
|
||||
showSecond: true,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result?.isActive.value).toBeFalsy();
|
||||
|
||||
vi.advanceTimersByTime(60 * SECOND);
|
||||
// value reflects construction-time "now" since no tick fired
|
||||
expect(result?.timeAgo.value).toBe('5 seconds ago');
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('stops updating once the scope is disposed (cleanup)', () => {
|
||||
const scope = effectScope();
|
||||
let timeAgo: ComputedRef<string> | undefined;
|
||||
|
||||
scope.run(() => {
|
||||
timeAgo = useTimeAgo(new Date(BASE), { updateInterval: 1000, showSecond: true });
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(60 * SECOND);
|
||||
expect(timeAgo?.value).toBe('1 minute ago');
|
||||
|
||||
scope.stop();
|
||||
|
||||
vi.advanceTimersByTime(120 * SECOND);
|
||||
expect(timeAgo?.value).toBe('1 minute ago');
|
||||
});
|
||||
|
||||
it('constructs without touching window/document/navigator (SSR-safe)', () => {
|
||||
// useTimeAgo is pure date math + an interval; it must build with no DOM
|
||||
// globals present. We simulate an SSR-ish absence and assert no throw.
|
||||
const originalWindow = (globalThis as Record<string, unknown>).window;
|
||||
const originalDocument = (globalThis as Record<string, unknown>).document;
|
||||
const originalNavigator = (globalThis as Record<string, unknown>).navigator;
|
||||
|
||||
vi.stubGlobal('window', undefined);
|
||||
vi.stubGlobal('document', undefined);
|
||||
vi.stubGlobal('navigator', undefined);
|
||||
|
||||
try {
|
||||
const scope = effectScope();
|
||||
let timeAgo: ComputedRef<string> | undefined;
|
||||
|
||||
scope.run(() => {
|
||||
timeAgo = useTimeAgo(new Date(BASE - 3 * MINUTE), { immediate: false });
|
||||
});
|
||||
|
||||
expect(timeAgo?.value).toBe('3 minutes ago');
|
||||
scope.stop();
|
||||
}
|
||||
finally {
|
||||
vi.unstubAllGlobals();
|
||||
// restore (vi.unstubAllGlobals already does, but keep references used)
|
||||
void originalWindow;
|
||||
void originalDocument;
|
||||
void originalNavigator;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,345 @@
|
||||
import { computed, shallowRef, toValue } from 'vue';
|
||||
import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue';
|
||||
import { isFunction, isNumber, isString } from '@robonen/stdlib';
|
||||
import type { ResumableActions } from '@/types';
|
||||
import { useIntervalFn } from '@/composables/animation/useIntervalFn';
|
||||
|
||||
/**
|
||||
* Formatter for a single unit value. Receives the rounded numeric value and
|
||||
* whether the instant is in the past, and returns the localized fragment.
|
||||
*/
|
||||
export type UseTimeAgoFormatter<T = number> = (value: T, isPast: boolean) => string;
|
||||
|
||||
/**
|
||||
* The default set of unit names recognized by `useTimeAgo`.
|
||||
*/
|
||||
export type UseTimeAgoUnitName
|
||||
= 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year';
|
||||
|
||||
/**
|
||||
* A single time unit used while resolving the most appropriate granularity.
|
||||
*/
|
||||
export interface UseTimeAgoUnit<Unit extends string = UseTimeAgoUnitName> {
|
||||
/**
|
||||
* Upper bound (exclusive) of the absolute diff (ms) this unit applies to
|
||||
*/
|
||||
max: number;
|
||||
|
||||
/**
|
||||
* Length of one unit in milliseconds
|
||||
*/
|
||||
value: number;
|
||||
|
||||
/**
|
||||
* Unit name; used to look up the matching message formatter
|
||||
*/
|
||||
name: Unit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in (non-unit) message slots.
|
||||
*/
|
||||
export interface UseTimeAgoMessagesBuiltIn {
|
||||
/**
|
||||
* Shown when the diff is below the smallest displayed unit
|
||||
*/
|
||||
justNow: string;
|
||||
|
||||
/**
|
||||
* Wraps a past fragment (e.g. `'3 minutes'` -> `'3 minutes ago'`)
|
||||
*/
|
||||
past: string | UseTimeAgoFormatter<string>;
|
||||
|
||||
/**
|
||||
* Wraps a future fragment (e.g. `'3 minutes'` -> `'in 3 minutes'`)
|
||||
*/
|
||||
future: string | UseTimeAgoFormatter<string>;
|
||||
|
||||
/**
|
||||
* Shown when the provided time cannot be parsed into a valid date
|
||||
*/
|
||||
invalid: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full message map: the built-in slots plus a formatter per unit name.
|
||||
*/
|
||||
export type UseTimeAgoMessages<UnitNames extends string = UseTimeAgoUnitName>
|
||||
= UseTimeAgoMessagesBuiltIn & Record<UnitNames, string | UseTimeAgoFormatter<number>>;
|
||||
|
||||
/**
|
||||
* Options shared by the pure `formatTimeAgo` and the reactive `useTimeAgo`.
|
||||
*/
|
||||
export interface FormatTimeAgoOptions<UnitNames extends string = UseTimeAgoUnitName> {
|
||||
/**
|
||||
* Maximum unit (or absolute ms diff) to display before falling back to
|
||||
* `fullDateFormatter`.
|
||||
*/
|
||||
max?: UnitNames | number;
|
||||
|
||||
/**
|
||||
* Formatter applied when the diff exceeds `max`.
|
||||
*
|
||||
* @default (date) => date.toISOString().slice(0, 10)
|
||||
*/
|
||||
fullDateFormatter?: (date: Date) => string;
|
||||
|
||||
/**
|
||||
* Localized messages.
|
||||
*/
|
||||
messages?: UseTimeAgoMessages<UnitNames>;
|
||||
|
||||
/**
|
||||
* Show seconds (i.e. allow sub-minute granularity) instead of `justNow`.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
showSecond?: boolean;
|
||||
|
||||
/**
|
||||
* Rounding strategy applied to unit values. A string maps to the matching
|
||||
* `Math` method; a number rounds to that many decimal places.
|
||||
*
|
||||
* @default 'round'
|
||||
*/
|
||||
rounding?: 'round' | 'ceil' | 'floor' | number;
|
||||
|
||||
/**
|
||||
* Custom ordered list of units (ascending by `value`).
|
||||
*/
|
||||
units?: Array<UseTimeAgoUnit<UnitNames>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for `useTimeAgo`.
|
||||
*/
|
||||
export interface UseTimeAgoOptions<Controls extends boolean, UnitNames extends string = UseTimeAgoUnitName>
|
||||
extends FormatTimeAgoOptions<UnitNames> {
|
||||
/**
|
||||
* Expose pause/resume controls alongside the time string.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
controls?: Controls;
|
||||
|
||||
/**
|
||||
* Interval (ms) at which the relative string is recomputed.
|
||||
*
|
||||
* @default 30000
|
||||
*/
|
||||
updateInterval?: number;
|
||||
|
||||
/**
|
||||
* Start the update interval immediately.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
immediate?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Controls returned when `controls: true`.
|
||||
*/
|
||||
export interface UseTimeAgoControls extends ResumableActions {
|
||||
/**
|
||||
* The reactive relative-time string
|
||||
*/
|
||||
timeAgo: ComputedRef<string>;
|
||||
|
||||
/**
|
||||
* Whether the update interval is currently active
|
||||
*/
|
||||
isActive: Readonly<Ref<boolean>>;
|
||||
}
|
||||
|
||||
export type UseTimeAgoReturn<Controls extends boolean = false>
|
||||
= Controls extends true ? UseTimeAgoControls : ComputedRef<string>;
|
||||
|
||||
const DEFAULT_UNITS: Array<UseTimeAgoUnit<UseTimeAgoUnitName>> = [
|
||||
{ max: 60000, value: 1000, name: 'second' },
|
||||
{ max: 2760000, value: 60000, name: 'minute' },
|
||||
{ max: 72000000, value: 3600000, name: 'hour' },
|
||||
{ max: 518400000, value: 86400000, name: 'day' },
|
||||
{ max: 2419200000, value: 604800000, name: 'week' },
|
||||
{ max: 28512000000, value: 2592000000, name: 'month' },
|
||||
{ max: Number.POSITIVE_INFINITY, value: 31536000000, name: 'year' },
|
||||
];
|
||||
|
||||
const DEFAULT_MESSAGES: UseTimeAgoMessages<UseTimeAgoUnitName> = {
|
||||
justNow: 'just now',
|
||||
past: n => /\d/.test(n) ? `${n} ago` : n,
|
||||
future: n => /\d/.test(n) ? `in ${n}` : n,
|
||||
month: (n, past) => n === 1 ? (past ? 'last month' : 'next month') : `${n} month${n > 1 ? 's' : ''}`,
|
||||
year: (n, past) => n === 1 ? (past ? 'last year' : 'next year') : `${n} year${n > 1 ? 's' : ''}`,
|
||||
day: (n, past) => n === 1 ? (past ? 'yesterday' : 'tomorrow') : `${n} day${n > 1 ? 's' : ''}`,
|
||||
week: (n, past) => n === 1 ? (past ? 'last week' : 'next week') : `${n} week${n > 1 ? 's' : ''}`,
|
||||
hour: n => `${n} hour${n > 1 ? 's' : ''}`,
|
||||
minute: n => `${n} minute${n > 1 ? 's' : ''}`,
|
||||
second: n => `${n} second${n > 1 ? 's' : ''}`,
|
||||
invalid: '',
|
||||
};
|
||||
|
||||
function defaultFullDateFormatter(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure (non-reactive) relative-time formatter. Useful on its own and reused by
|
||||
* `useTimeAgo` on every tick.
|
||||
*
|
||||
* @param {Date} from The instant to describe
|
||||
* @param {FormatTimeAgoOptions} [options={}] Formatting options
|
||||
* @param {Date | number} [now=Date.now()] The reference "now"
|
||||
* @returns {string} The localized relative-time string
|
||||
*
|
||||
* @example
|
||||
* formatTimeAgo(new Date(Date.now() - 3 * 60_000)); // '3 minutes ago'
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function formatTimeAgo<UnitNames extends string = UseTimeAgoUnitName>(
|
||||
from: Date,
|
||||
options: FormatTimeAgoOptions<UnitNames> = {},
|
||||
now: Date | number = Date.now(),
|
||||
): string {
|
||||
const {
|
||||
max,
|
||||
messages = DEFAULT_MESSAGES as UseTimeAgoMessages<UnitNames>,
|
||||
fullDateFormatter = defaultFullDateFormatter,
|
||||
units = DEFAULT_UNITS as Array<UseTimeAgoUnit<UnitNames>>,
|
||||
showSecond = false,
|
||||
rounding = 'round',
|
||||
} = options;
|
||||
|
||||
const fromMs = +from;
|
||||
|
||||
if (Number.isNaN(fromMs))
|
||||
return messages.invalid;
|
||||
|
||||
const roundFn = isNumber(rounding)
|
||||
? (n: number): number => +n.toFixed(rounding)
|
||||
: Math[rounding];
|
||||
|
||||
const diff = +now - fromMs;
|
||||
const absDiff = Math.abs(diff);
|
||||
|
||||
function getValue(unit: UseTimeAgoUnit<UnitNames>): number {
|
||||
return roundFn(absDiff / unit.value);
|
||||
}
|
||||
|
||||
function applyFormat(
|
||||
name: UnitNames | keyof UseTimeAgoMessagesBuiltIn,
|
||||
val: number | string,
|
||||
isPast: boolean,
|
||||
): string {
|
||||
const formatter = messages[name];
|
||||
|
||||
if (isFunction(formatter))
|
||||
return formatter(val as never, isPast);
|
||||
|
||||
return formatter.replace('{0}', val.toString());
|
||||
}
|
||||
|
||||
function format(unit: UseTimeAgoUnit<UnitNames>): string {
|
||||
const val = getValue(unit);
|
||||
const past = diff > 0;
|
||||
const str = applyFormat(unit.name, val, past);
|
||||
|
||||
return applyFormat(past ? 'past' : 'future', str, past);
|
||||
}
|
||||
|
||||
if (absDiff < 60000 && !showSecond)
|
||||
return messages.justNow;
|
||||
|
||||
if (isNumber(max) && absDiff > max)
|
||||
return fullDateFormatter(new Date(from));
|
||||
|
||||
if (isString(max)) {
|
||||
const unitMax = units.find(unit => unit.name === max)?.max;
|
||||
|
||||
if (unitMax && absDiff > unitMax)
|
||||
return fullDateFormatter(new Date(from));
|
||||
}
|
||||
|
||||
for (let idx = 0; idx < units.length; idx++) {
|
||||
const unit = units[idx]!;
|
||||
const prev = units[idx - 1];
|
||||
|
||||
if (getValue(unit) <= 0 && prev)
|
||||
return format(prev);
|
||||
|
||||
if (absDiff < unit.max)
|
||||
return format(unit);
|
||||
}
|
||||
|
||||
return messages.invalid;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useTimeAgo
|
||||
* @category Animation
|
||||
* @description Reactive relative time string (e.g. `'3 minutes ago'`) that
|
||||
* ticks on a fixed interval. Fully customizable messages (i18n), units,
|
||||
* rounding, and an automatic fallback to a full date once `max` is exceeded.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<Date | number | string>} time The instant to describe (reactive)
|
||||
* @param {UseTimeAgoOptions} [options={}] Options
|
||||
* @returns {ComputedRef<string> | UseTimeAgoControls} The reactive string, or controls when `controls: true`
|
||||
*
|
||||
* @example
|
||||
* const timeAgo = useTimeAgo(new Date(Date.now() - 60_000)); // '1 minute ago'
|
||||
*
|
||||
* @example
|
||||
* // With pause/resume controls and a custom update cadence
|
||||
* const { timeAgo, pause, resume } = useTimeAgo(date, { controls: true, updateInterval: 1000 });
|
||||
*
|
||||
* @example
|
||||
* // i18n + full-date fallback past one month
|
||||
* const timeAgo = useTimeAgo(date, {
|
||||
* max: 'month',
|
||||
* messages: { ...customMessages },
|
||||
* fullDateFormatter: d => d.toLocaleDateString('fr-FR'),
|
||||
* });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useTimeAgo<UnitNames extends string = UseTimeAgoUnitName>(
|
||||
time: MaybeRefOrGetter<Date | number | string>,
|
||||
options?: UseTimeAgoOptions<false, UnitNames>,
|
||||
): ComputedRef<string>;
|
||||
export function useTimeAgo<UnitNames extends string = UseTimeAgoUnitName>(
|
||||
time: MaybeRefOrGetter<Date | number | string>,
|
||||
options: UseTimeAgoOptions<true, UnitNames>,
|
||||
): UseTimeAgoControls;
|
||||
export function useTimeAgo<UnitNames extends string = UseTimeAgoUnitName>(
|
||||
time: MaybeRefOrGetter<Date | number | string>,
|
||||
options: UseTimeAgoOptions<boolean, UnitNames> = {},
|
||||
): ComputedRef<string> | UseTimeAgoControls {
|
||||
const {
|
||||
controls = false,
|
||||
updateInterval = 30000,
|
||||
immediate = true,
|
||||
} = options;
|
||||
|
||||
// A single ticking ref drives recomputation; the heavy formatting stays in
|
||||
// a computed so it only runs when `now` or `time` actually change.
|
||||
const now = shallowRef(Date.now());
|
||||
|
||||
const resumable = useIntervalFn(() => {
|
||||
now.value = Date.now();
|
||||
}, updateInterval, { immediate });
|
||||
|
||||
const timeAgo = computed(() => formatTimeAgo(new Date(toValue(time)), options, now.value));
|
||||
|
||||
if (controls) {
|
||||
return {
|
||||
timeAgo,
|
||||
isActive: resumable.isActive,
|
||||
pause: resumable.pause,
|
||||
resume: resumable.resume,
|
||||
toggle: resumable.toggle,
|
||||
};
|
||||
}
|
||||
|
||||
return timeAgo;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, isReadonly, ref } from 'vue';
|
||||
import { useTimeout } from '.';
|
||||
|
||||
describe(useTimeout, () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('flips ready to true after the interval', () => {
|
||||
const ready = useTimeout(100);
|
||||
|
||||
expect(ready.value).toBeFalsy();
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(ready.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('defaults the interval to 1000ms', () => {
|
||||
const ready = useTimeout();
|
||||
|
||||
vi.advanceTimersByTime(999);
|
||||
expect(ready.value).toBeFalsy();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(ready.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns a read-only computed by default', () => {
|
||||
const ready = useTimeout(100);
|
||||
|
||||
expect(isReadonly(ready)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('starts ready when immediate is false', () => {
|
||||
const ready = useTimeout(100, { immediate: false });
|
||||
|
||||
expect(ready.value).toBeTruthy();
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(ready.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('invokes the callback when the timeout elapses', () => {
|
||||
const callback = vi.fn();
|
||||
useTimeout(100, { callback });
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('reads the interval reactively each time it starts', () => {
|
||||
const delay = ref(100);
|
||||
const { ready, start } = useTimeout(delay, { controls: true, immediate: false });
|
||||
|
||||
delay.value = 200;
|
||||
start();
|
||||
expect(ready.value).toBeFalsy();
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(ready.value).toBeFalsy();
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(ready.value).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('controls', () => {
|
||||
it('exposes ready, start and stop', () => {
|
||||
const controls = useTimeout(100, { controls: true });
|
||||
|
||||
expect(controls).toHaveProperty('ready');
|
||||
expect(controls).toHaveProperty('start');
|
||||
expect(controls).toHaveProperty('stop');
|
||||
});
|
||||
|
||||
it('start restarts the pending timeout', () => {
|
||||
const { ready, start } = useTimeout(100, { controls: true });
|
||||
|
||||
vi.advanceTimersByTime(50);
|
||||
start();
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(ready.value).toBeFalsy();
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(ready.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('start re-arms the timeout after it has elapsed', () => {
|
||||
const callback = vi.fn();
|
||||
const { ready, start } = useTimeout(100, { controls: true, callback });
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(ready.value).toBeTruthy();
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
|
||||
start();
|
||||
expect(ready.value).toBeFalsy();
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(ready.value).toBeTruthy();
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('stop cancels the pending callback', () => {
|
||||
const callback = vi.fn();
|
||||
const { stop } = useTimeout(100, { controls: true, callback });
|
||||
|
||||
stop();
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('cleans up on scope dispose', () => {
|
||||
const callback = vi.fn();
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
useTimeout(100, { callback });
|
||||
});
|
||||
|
||||
scope.stop();
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not auto-start the real timer in a non-client (SSR) environment', () => {
|
||||
// `useTimeoutFn` is guarded by `isClient`; with immediate auto-start it
|
||||
// marks the timeout pending but only schedules a timer on the client.
|
||||
// We assert the SSR-safe contract: no callback fires without timers running.
|
||||
const callback = vi.fn();
|
||||
const { ready } = useTimeout(100, { controls: true, immediate: false, callback });
|
||||
|
||||
// immediate:false means never auto-armed -> ready stays true, callback never fires
|
||||
expect(ready.value).toBeTruthy();
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { computed } from 'vue';
|
||||
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
|
||||
import type { VoidFunction } from '@robonen/stdlib';
|
||||
import { noop } from '@robonen/stdlib';
|
||||
import { useTimeoutFn } from '@/composables/animation/useTimeoutFn';
|
||||
import type { UseTimeoutFnOptions, UseTimeoutFnReturn } from '@/composables/animation/useTimeoutFn';
|
||||
|
||||
export interface UseTimeoutOptions<Controls extends boolean> extends UseTimeoutFnOptions {
|
||||
/**
|
||||
* Expose `start`/`stop` controls alongside the `ready` flag
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
controls?: Controls;
|
||||
|
||||
/**
|
||||
* Callback invoked when the timeout elapses
|
||||
*/
|
||||
callback?: VoidFunction;
|
||||
}
|
||||
|
||||
export interface UseTimeoutControls {
|
||||
/**
|
||||
* Reactive flag that is `false` while the timeout is pending and flips to
|
||||
* `true` once the delay has elapsed
|
||||
*/
|
||||
ready: ComputedRef<boolean>;
|
||||
|
||||
/**
|
||||
* Start (or restart) the timeout
|
||||
*/
|
||||
start: UseTimeoutFnReturn<[]>['start'];
|
||||
|
||||
/**
|
||||
* Cancel the pending timeout (leaves `ready` at its current value)
|
||||
*/
|
||||
stop: UseTimeoutFnReturn<[]>['stop'];
|
||||
}
|
||||
|
||||
export type UseTimeoutReturn
|
||||
= ComputedRef<boolean> | UseTimeoutControls;
|
||||
|
||||
/**
|
||||
* @name useTimeout
|
||||
* @category Animation
|
||||
* @description Reactive boolean that flips to `true` after a given delay.
|
||||
* Built on `useTimeoutFn`; optionally exposes `start`/`stop` controls. SSR-safe.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<number>} [interval=1000] Delay in milliseconds (resolved each time the timeout starts, can be reactive)
|
||||
* @param {UseTimeoutOptions} [options={}] Options
|
||||
* @returns {ComputedRef<boolean> | UseTimeoutControls} The read-only `ready` flag, or controls when `controls: true`
|
||||
*
|
||||
* @example
|
||||
* const ready = useTimeout(1000);
|
||||
* // `ready.value` becomes true after 1s
|
||||
*
|
||||
* @example
|
||||
* const { ready, start, stop } = useTimeout(1000, { controls: true });
|
||||
*
|
||||
* @example
|
||||
* // Run a callback when the timeout elapses
|
||||
* useTimeout(5000, { callback: refresh });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useTimeout(interval?: MaybeRefOrGetter<number>, options?: UseTimeoutOptions<false>): ComputedRef<boolean>;
|
||||
export function useTimeout(interval: MaybeRefOrGetter<number>, options: UseTimeoutOptions<true>): UseTimeoutControls;
|
||||
export function useTimeout(
|
||||
interval: MaybeRefOrGetter<number> = 1000,
|
||||
options: UseTimeoutOptions<boolean> = {},
|
||||
): UseTimeoutReturn {
|
||||
const {
|
||||
controls: exposeControls = false,
|
||||
callback = noop,
|
||||
} = options;
|
||||
|
||||
const { isPending, start, stop } = useTimeoutFn(callback, interval, options);
|
||||
|
||||
const ready = computed(() => !isPending.value);
|
||||
|
||||
if (exposeControls) {
|
||||
return {
|
||||
ready,
|
||||
start,
|
||||
stop,
|
||||
};
|
||||
}
|
||||
|
||||
return ready;
|
||||
}
|
||||
+1
-1
@@ -40,7 +40,7 @@ export interface UseTimeoutFnReturn<Args extends any[]> {
|
||||
|
||||
/**
|
||||
* @name useTimeoutFn
|
||||
* @category Utilities
|
||||
* @category Animation
|
||||
* @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.
|
||||
*
|
||||
+3
-3
@@ -2,8 +2,8 @@ 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';
|
||||
import { useRafFn } from '@/composables/animation/useRafFn';
|
||||
import { useIntervalFn } from '@/composables/animation/useIntervalFn';
|
||||
|
||||
export interface UseTimestampOptions<Controls extends boolean> {
|
||||
/**
|
||||
@@ -64,7 +64,7 @@ export type UseTimestampReturn<Controls extends boolean> = Controls extends true
|
||||
|
||||
/**
|
||||
* @name useTimestamp
|
||||
* @category Utilities
|
||||
* @category Animation
|
||||
* @description Reactive current timestamp, updated via `requestAnimationFrame`
|
||||
* or a fixed interval.
|
||||
*
|
||||
@@ -0,0 +1,315 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick, ref } from 'vue';
|
||||
import {
|
||||
TransitionPresets,
|
||||
useTransition,
|
||||
} from '@/composables/animation/useTransition';
|
||||
|
||||
// A controllable window stub: requestAnimationFrame frames are flushed
|
||||
// manually via `frame()`, and timers are driven by vitest fake timers.
|
||||
function createWindowStub() {
|
||||
let rafId = 0;
|
||||
const callbacks = new Map<number, FrameRequestCallback>();
|
||||
|
||||
const win = {
|
||||
requestAnimationFrame: (cb: FrameRequestCallback) => {
|
||||
const id = ++rafId;
|
||||
callbacks.set(id, cb);
|
||||
return id;
|
||||
},
|
||||
cancelAnimationFrame: (id: number) => {
|
||||
callbacks.delete(id);
|
||||
},
|
||||
setTimeout: (fn: (...args: unknown[]) => void, ms?: number) =>
|
||||
setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id: number) => clearTimeout(id),
|
||||
} as unknown as Window;
|
||||
|
||||
function frame() {
|
||||
const pending = [...callbacks.entries()];
|
||||
callbacks.clear();
|
||||
for (const [, cb] of pending)
|
||||
cb(Date.now());
|
||||
}
|
||||
|
||||
return { win, frame, get pending() {
|
||||
return callbacks.size;
|
||||
} };
|
||||
}
|
||||
|
||||
describe(useTransition, () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('seeds output with the initial source value', () => {
|
||||
const { win } = createWindowStub();
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
const output = useTransition(ref(5), { window: win });
|
||||
expect(output.value).toBe(5);
|
||||
});
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('transitions a number from old to new over the duration', async () => {
|
||||
const { win, frame } = createWindowStub();
|
||||
vi.setSystemTime(0);
|
||||
const scope = effectScope();
|
||||
const source = ref(0);
|
||||
let output!: ReturnType<typeof useTransition<number>>;
|
||||
|
||||
scope.run(() => {
|
||||
output = useTransition(source, { duration: 100, window: win });
|
||||
});
|
||||
|
||||
source.value = 100;
|
||||
await nextTick();
|
||||
|
||||
// Halfway through.
|
||||
vi.setSystemTime(50);
|
||||
frame();
|
||||
expect(output.value).toBeCloseTo(50, 5);
|
||||
|
||||
// End.
|
||||
vi.setSystemTime(100);
|
||||
frame();
|
||||
expect(output.value).toBe(100);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('fires onStarted and onFinished', async () => {
|
||||
const { win, frame } = createWindowStub();
|
||||
vi.setSystemTime(0);
|
||||
const onStarted = vi.fn();
|
||||
const onFinished = vi.fn();
|
||||
const scope = effectScope();
|
||||
const source = ref(0);
|
||||
|
||||
scope.run(() => {
|
||||
useTransition(source, { duration: 100, window: win, onStarted, onFinished });
|
||||
});
|
||||
|
||||
source.value = 10;
|
||||
await nextTick();
|
||||
|
||||
expect(onStarted).toHaveBeenCalledTimes(1);
|
||||
expect(onFinished).not.toHaveBeenCalled();
|
||||
|
||||
vi.setSystemTime(100);
|
||||
frame();
|
||||
|
||||
expect(onFinished).toHaveBeenCalledTimes(1);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('transitions numeric arrays element-wise', async () => {
|
||||
const { win, frame } = createWindowStub();
|
||||
vi.setSystemTime(0);
|
||||
const scope = effectScope();
|
||||
const source = ref([0, 100]);
|
||||
let output!: ReturnType<typeof useTransition<number[]>>;
|
||||
|
||||
scope.run(() => {
|
||||
output = useTransition(source, { duration: 100, window: win });
|
||||
});
|
||||
|
||||
source.value = [100, 0];
|
||||
await nextTick();
|
||||
|
||||
vi.setSystemTime(50);
|
||||
frame();
|
||||
expect(output.value[0]).toBeCloseTo(50, 5);
|
||||
expect(output.value[1]).toBeCloseTo(50, 5);
|
||||
|
||||
vi.setSystemTime(100);
|
||||
frame();
|
||||
expect(output.value).toEqual([100, 0]);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('applies an easing preset (eased value differs from linear midpoint)', async () => {
|
||||
const { win, frame } = createWindowStub();
|
||||
vi.setSystemTime(0);
|
||||
const scope = effectScope();
|
||||
const source = ref(0);
|
||||
let output!: ReturnType<typeof useTransition<number>>;
|
||||
|
||||
scope.run(() => {
|
||||
output = useTransition(source, {
|
||||
duration: 100,
|
||||
transition: TransitionPresets.easeInCubic,
|
||||
window: win,
|
||||
});
|
||||
});
|
||||
|
||||
source.value = 100;
|
||||
await nextTick();
|
||||
|
||||
vi.setSystemTime(50);
|
||||
frame();
|
||||
// easeInCubic at t=0.5 is well below the linear midpoint of 50.
|
||||
expect(output.value).toBeLessThan(50);
|
||||
expect(output.value).toBeGreaterThan(0);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('accepts a custom easing function', async () => {
|
||||
const { win, frame } = createWindowStub();
|
||||
vi.setSystemTime(0);
|
||||
const scope = effectScope();
|
||||
const source = ref(0);
|
||||
const easing = vi.fn((n: number) => n);
|
||||
let output!: ReturnType<typeof useTransition<number>>;
|
||||
|
||||
scope.run(() => {
|
||||
output = useTransition(source, { duration: 100, transition: easing, window: win });
|
||||
});
|
||||
|
||||
source.value = 100;
|
||||
await nextTick();
|
||||
|
||||
vi.setSystemTime(50);
|
||||
frame();
|
||||
|
||||
expect(easing).toHaveBeenCalled();
|
||||
expect(output.value).toBeCloseTo(50, 5);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('delays the start of a transition', async () => {
|
||||
const { win, frame } = createWindowStub();
|
||||
vi.setSystemTime(0);
|
||||
const onStarted = vi.fn();
|
||||
const scope = effectScope();
|
||||
const source = ref(0);
|
||||
|
||||
scope.run(() => {
|
||||
useTransition(source, { duration: 100, delay: 200, window: win, onStarted });
|
||||
});
|
||||
|
||||
source.value = 100;
|
||||
await nextTick();
|
||||
|
||||
// Still in the delay window: not started yet.
|
||||
expect(onStarted).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(onStarted).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.setSystemTime(200);
|
||||
frame();
|
||||
expect(onStarted).toHaveBeenCalledTimes(1);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('snaps instantly when disabled and tracks the source', async () => {
|
||||
const { win, frame, pending } = createWindowStub();
|
||||
const scope = effectScope();
|
||||
const source = ref(0);
|
||||
let output!: ReturnType<typeof useTransition<number>>;
|
||||
|
||||
scope.run(() => {
|
||||
output = useTransition(source, { duration: 100, disabled: true, window: win });
|
||||
});
|
||||
|
||||
source.value = 42;
|
||||
await nextTick();
|
||||
|
||||
expect(output.value).toBe(42);
|
||||
expect(pending).toBe(0);
|
||||
frame();
|
||||
expect(output.value).toBe(42);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('disabling mid-transition snaps to the source', async () => {
|
||||
const { win, frame } = createWindowStub();
|
||||
vi.setSystemTime(0);
|
||||
const scope = effectScope();
|
||||
const source = ref(0);
|
||||
const disabled = ref(false);
|
||||
let output!: ReturnType<typeof useTransition<number>>;
|
||||
|
||||
scope.run(() => {
|
||||
output = useTransition(source, { duration: 100, disabled, window: win });
|
||||
});
|
||||
|
||||
source.value = 100;
|
||||
await nextTick();
|
||||
|
||||
vi.setSystemTime(50);
|
||||
frame();
|
||||
expect(output.value).toBeCloseTo(50, 5);
|
||||
|
||||
disabled.value = true;
|
||||
await nextTick();
|
||||
|
||||
expect(output.value).toBe(100);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('jumps immediately when duration is zero', async () => {
|
||||
const { win } = createWindowStub();
|
||||
vi.setSystemTime(0);
|
||||
const onFinished = vi.fn();
|
||||
const scope = effectScope();
|
||||
const source = ref(0);
|
||||
let output!: ReturnType<typeof useTransition<number>>;
|
||||
|
||||
scope.run(() => {
|
||||
output = useTransition(source, { duration: 0, window: win, onFinished });
|
||||
});
|
||||
|
||||
source.value = 100;
|
||||
await nextTick();
|
||||
|
||||
expect(output.value).toBe(100);
|
||||
expect(onFinished).toHaveBeenCalledTimes(1);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('is SSR-safe: without a window it mirrors the source synchronously', async () => {
|
||||
const onFinished = vi.fn();
|
||||
const scope = effectScope();
|
||||
const source = ref(0);
|
||||
let output!: ReturnType<typeof useTransition<number>>;
|
||||
|
||||
scope.run(() => {
|
||||
output = useTransition(source, { duration: 100, window: undefined, onFinished });
|
||||
});
|
||||
|
||||
expect(output.value).toBe(0);
|
||||
|
||||
source.value = 100;
|
||||
await nextTick();
|
||||
|
||||
// No RAF available: transition resolves immediately to the target.
|
||||
expect(output.value).toBe(100);
|
||||
expect(onFinished).toHaveBeenCalledTimes(1);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('exposes the documented easing presets', () => {
|
||||
expect(TransitionPresets.linear).toEqual([0, 0, 1, 1]);
|
||||
expect(TransitionPresets.easeInOutCubic).toEqual([0.65, 0, 0.35, 1]);
|
||||
expect(Object.keys(TransitionPresets)).toContain('easeOutBack');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
import { computed, ref, toValue, watch } from 'vue';
|
||||
import type { MaybeRefOrGetter, Ref } from 'vue';
|
||||
import { clamp, isFunction, isNumber, lerp, noop } from '@robonen/stdlib';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { useRafFn } from '@/composables/animation/useRafFn';
|
||||
|
||||
/**
|
||||
* Cubic bezier control points `[x1, y1, x2, y2]` (the implied endpoints are
|
||||
* `(0, 0)` and `(1, 1)`), matching the CSS `cubic-bezier()` argument order.
|
||||
*/
|
||||
export type CubicBezierPoints = [number, number, number, number];
|
||||
|
||||
/**
|
||||
* An easing function mapping linear progress in `[0, 1]` to eased progress.
|
||||
*/
|
||||
export type EasingFunction = (n: number) => number;
|
||||
|
||||
/**
|
||||
* Interpolates between two values of `T` given an eased progress `alpha`.
|
||||
*/
|
||||
export type TransitionInterpolation<T> = (from: T, to: T, alpha: number) => T;
|
||||
|
||||
/**
|
||||
* The transition easing: either a cubic bezier tuple or a custom easing function.
|
||||
*/
|
||||
export type TransitionEasing = CubicBezierPoints | EasingFunction;
|
||||
|
||||
/**
|
||||
* Values that can be transitioned: a single number or a fixed-length number array.
|
||||
*/
|
||||
export type TransitionValue = number | number[];
|
||||
|
||||
/**
|
||||
* Common cubic bezier easing presets (same curves as CSS / VueUse).
|
||||
*/
|
||||
export const TransitionPresets = {
|
||||
linear: [0, 0, 1, 1],
|
||||
easeInSine: [0.12, 0, 0.39, 0],
|
||||
easeOutSine: [0.61, 1, 0.88, 1],
|
||||
easeInOutSine: [0.37, 0, 0.63, 1],
|
||||
easeInQuad: [0.11, 0, 0.5, 0],
|
||||
easeOutQuad: [0.5, 1, 0.89, 1],
|
||||
easeInOutQuad: [0.45, 0, 0.55, 1],
|
||||
easeInCubic: [0.32, 0, 0.67, 0],
|
||||
easeOutCubic: [0.33, 1, 0.68, 1],
|
||||
easeInOutCubic: [0.65, 0, 0.35, 1],
|
||||
easeInQuart: [0.5, 0, 0.75, 0],
|
||||
easeOutQuart: [0.25, 1, 0.5, 1],
|
||||
easeInOutQuart: [0.76, 0, 0.24, 1],
|
||||
easeInQuint: [0.64, 0, 0.78, 0],
|
||||
easeOutQuint: [0.22, 1, 0.36, 1],
|
||||
easeInOutQuint: [0.83, 0, 0.17, 1],
|
||||
easeInExpo: [0.7, 0, 0.84, 0],
|
||||
easeOutExpo: [0.16, 1, 0.3, 1],
|
||||
easeInOutExpo: [0.87, 0, 0.13, 1],
|
||||
easeInCirc: [0.55, 0, 1, 0.45],
|
||||
easeOutCirc: [0, 0.55, 0.45, 1],
|
||||
easeInOutCirc: [0.85, 0, 0.15, 1],
|
||||
easeInBack: [0.36, 0, 0.66, -0.56],
|
||||
easeOutBack: [0.34, 1.56, 0.64, 1],
|
||||
easeInOutBack: [0.68, -0.6, 0.32, 1.6],
|
||||
} satisfies Record<string, CubicBezierPoints>;
|
||||
|
||||
export interface UseTransitionOptions {
|
||||
/**
|
||||
* Transition duration in milliseconds. Accepts a reactive value (resolved
|
||||
* at the start of each transition).
|
||||
*
|
||||
* @default 1000
|
||||
*/
|
||||
duration?: MaybeRefOrGetter<number>;
|
||||
|
||||
/**
|
||||
* Easing applied to the progress: a cubic bezier tuple (e.g. one of
|
||||
* {@link TransitionPresets}) or a custom easing function.
|
||||
*
|
||||
* @default identity (linear)
|
||||
*/
|
||||
transition?: MaybeRefOrGetter<TransitionEasing>;
|
||||
|
||||
/**
|
||||
* Delay in milliseconds before a transition begins after the source changes.
|
||||
*
|
||||
* @default 0
|
||||
*/
|
||||
delay?: MaybeRefOrGetter<number>;
|
||||
|
||||
/**
|
||||
* When `true`, transitions are skipped and the output tracks the source
|
||||
* value directly (no animation). Reactive.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
disabled?: MaybeRefOrGetter<boolean>;
|
||||
|
||||
/**
|
||||
* Called when a transition starts.
|
||||
*/
|
||||
onStarted?: () => void;
|
||||
|
||||
/**
|
||||
* Called when a transition finishes (not called when aborted by a new change).
|
||||
*/
|
||||
onFinished?: () => void;
|
||||
}
|
||||
|
||||
export type UseTransitionReturn<T> = Readonly<Ref<T>>;
|
||||
|
||||
const identity: EasingFunction = n => n;
|
||||
|
||||
interface BezierCoefficients {
|
||||
a: (a1: number, a2: number) => number;
|
||||
b: (a1: number, a2: number) => number;
|
||||
c: (a1: number) => number;
|
||||
}
|
||||
|
||||
function createEasingFunction(points: CubicBezierPoints): EasingFunction {
|
||||
const [p0, p1, p2, p3] = points;
|
||||
|
||||
const coeffs: BezierCoefficients = {
|
||||
a: (a1, a2) => 1 - 3 * a2 + 3 * a1,
|
||||
b: (a1, a2) => 3 * a2 - 6 * a1,
|
||||
c: a1 => 3 * a1,
|
||||
};
|
||||
|
||||
const calcBezier = (t: number, a1: number, a2: number): number =>
|
||||
((coeffs.a(a1, a2) * t + coeffs.b(a1, a2)) * t + coeffs.c(a1)) * t;
|
||||
|
||||
const getSlope = (t: number, a1: number, a2: number): number =>
|
||||
3 * coeffs.a(a1, a2) * t * t + 2 * coeffs.b(a1, a2) * t + coeffs.c(a1);
|
||||
|
||||
const getTForX = (x: number): number => {
|
||||
let guess = x;
|
||||
|
||||
for (let i = 0; i < 4; ++i) {
|
||||
const slope = getSlope(guess, p0, p2);
|
||||
|
||||
if (slope === 0)
|
||||
return guess;
|
||||
|
||||
const currentX = calcBezier(guess, p0, p2) - x;
|
||||
guess -= currentX / slope;
|
||||
}
|
||||
|
||||
return guess;
|
||||
};
|
||||
|
||||
return n => (p0 === p1 && p2 === p3) ? n : calcBezier(getTForX(n), p1, p3);
|
||||
}
|
||||
|
||||
function resolveEasing(transition: TransitionEasing | undefined): EasingFunction {
|
||||
if (!transition)
|
||||
return identity;
|
||||
|
||||
if (isFunction(transition))
|
||||
return transition;
|
||||
|
||||
return createEasingFunction(transition);
|
||||
}
|
||||
|
||||
// Interpolate a single number or a (fixed-length) numeric array.
|
||||
function interpolate(from: TransitionValue, to: TransitionValue, alpha: number): TransitionValue {
|
||||
if (isNumber(from) && isNumber(to))
|
||||
return lerp(from, to, alpha);
|
||||
|
||||
const source = from as number[];
|
||||
const target = to as number[];
|
||||
|
||||
return source.map((value, index) => lerp(value, target[index] ?? value, alpha));
|
||||
}
|
||||
|
||||
function snapshot<T extends TransitionValue>(value: T): T {
|
||||
return (isNumber(value) ? value : (value as number[]).slice()) as T;
|
||||
}
|
||||
|
||||
function valuesEqual(a: TransitionValue, b: TransitionValue): boolean {
|
||||
if (isNumber(a) && isNumber(b))
|
||||
return a === b;
|
||||
|
||||
if (isNumber(a) || isNumber(b))
|
||||
return false;
|
||||
|
||||
if (a.length !== b.length)
|
||||
return false;
|
||||
|
||||
for (let i = 0; i < a.length; ++i) {
|
||||
if (a[i] !== b[i])
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useTransition
|
||||
* @category Animation
|
||||
* @description Reactively transition between numeric values (or numeric arrays)
|
||||
* over a duration with configurable easing. Wraps a single, paused
|
||||
* `requestAnimationFrame` loop that only runs while a transition is in flight,
|
||||
* so it is cheaper than re-creating an RAF loop per change. SSR-safe: without a
|
||||
* `window` the output tracks the source synchronously.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<T>} source The reactive source value (number or number[])
|
||||
* @param {UseTransitionOptions & ConfigurableWindow} [options={}] Transition options
|
||||
* @returns {UseTransitionReturn<T>} A readonly ref of the transitioned value
|
||||
*
|
||||
* @example
|
||||
* const progress = ref(0);
|
||||
* const output = useTransition(progress, {
|
||||
* duration: 500,
|
||||
* transition: TransitionPresets.easeOutCubic,
|
||||
* });
|
||||
*
|
||||
* @example
|
||||
* // Transition a tuple (e.g. an RGB color)
|
||||
* const color = ref([0, 0, 0]);
|
||||
* const animated = useTransition(color, { duration: 1000 });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useTransition<T extends TransitionValue>(
|
||||
source: MaybeRefOrGetter<T>,
|
||||
options: UseTransitionOptions & ConfigurableWindow = {},
|
||||
): UseTransitionReturn<T> {
|
||||
const {
|
||||
duration = 1000,
|
||||
transition = identity,
|
||||
delay = 0,
|
||||
disabled = false,
|
||||
onStarted = noop,
|
||||
onFinished = noop,
|
||||
} = options;
|
||||
|
||||
const window = 'window' in options ? options.window : defaultWindow;
|
||||
|
||||
// The animated output. Seeded with a snapshot of the current source.
|
||||
const outputRef = ref(snapshot(toValue(source))) as Ref<T>;
|
||||
|
||||
// Active-transition state. `endpoints` are detached snapshots so that later
|
||||
// source mutations cannot bleed into an in-flight transition.
|
||||
let fromValue: T = outputRef.value;
|
||||
let toValue_: T = outputRef.value;
|
||||
let startedAt = 0;
|
||||
let durationMs = 0;
|
||||
let easing: EasingFunction = identity;
|
||||
let delayTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let finishPending = false;
|
||||
|
||||
const { pause, resume, isActive } = useRafFn(tick, { immediate: false, window });
|
||||
|
||||
function clearDelay() {
|
||||
if (delayTimer !== null && window) {
|
||||
window.clearTimeout(delayTimer);
|
||||
delayTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function settle(value: T) {
|
||||
outputRef.value = snapshot(value);
|
||||
|
||||
if (isActive.value)
|
||||
pause();
|
||||
|
||||
if (finishPending) {
|
||||
finishPending = false;
|
||||
onFinished();
|
||||
}
|
||||
}
|
||||
|
||||
function tick() {
|
||||
const now = Date.now();
|
||||
const alpha = durationMs <= 0 ? 1 : clamp((now - startedAt) / durationMs, 0, 1);
|
||||
|
||||
outputRef.value = interpolate(fromValue, toValue_, easing(alpha)) as T;
|
||||
|
||||
if (alpha >= 1)
|
||||
settle(toValue_);
|
||||
}
|
||||
|
||||
function begin(target: T) {
|
||||
fromValue = snapshot(outputRef.value);
|
||||
toValue_ = snapshot(target);
|
||||
|
||||
durationMs = Math.max(0, toValue(duration));
|
||||
easing = resolveEasing(toValue(transition));
|
||||
startedAt = Date.now();
|
||||
finishPending = true;
|
||||
|
||||
onStarted();
|
||||
|
||||
if (durationMs <= 0 || !window) {
|
||||
settle(toValue_);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isActive.value)
|
||||
resume();
|
||||
}
|
||||
|
||||
function start(target: T) {
|
||||
clearDelay();
|
||||
|
||||
const delayMs = Math.max(0, toValue(delay));
|
||||
|
||||
if (delayMs > 0 && window) {
|
||||
delayTimer = window.setTimeout(() => {
|
||||
delayTimer = null;
|
||||
begin(target);
|
||||
}, delayMs);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
begin(target);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => toValue(source),
|
||||
(value) => {
|
||||
// When disabled, mirror the source instantly and abort any animation.
|
||||
if (toValue(disabled)) {
|
||||
clearDelay();
|
||||
|
||||
if (isActive.value)
|
||||
pause();
|
||||
|
||||
finishPending = false;
|
||||
outputRef.value = snapshot(value);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip no-op changes so we don't restart an identical transition.
|
||||
if (valuesEqual(value, outputRef.value) && !isActive.value && delayTimer === null)
|
||||
return;
|
||||
|
||||
start(value);
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
// Reacting to `disabled` flipping to true mid-transition: snap to source.
|
||||
watch(
|
||||
() => toValue(disabled),
|
||||
(off) => {
|
||||
if (off) {
|
||||
clearDelay();
|
||||
|
||||
if (isActive.value)
|
||||
pause();
|
||||
|
||||
finishPending = false;
|
||||
outputRef.value = snapshot(toValue(source));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return computed(() => outputRef.value);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export * from './useArrayDifference';
|
||||
export * from './useArrayEvery';
|
||||
export * from './useArrayFilter';
|
||||
export * from './useArrayFind';
|
||||
export * from './useArrayFindIndex';
|
||||
export * from './useArrayFindLast';
|
||||
export * from './useArrayIncludes';
|
||||
export * from './useArrayJoin';
|
||||
export * from './useArrayMap';
|
||||
export * from './useArrayReduce';
|
||||
export * from './useArraySome';
|
||||
export * from './useArrayUnique';
|
||||
export * from './useSorted';
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { useArrayDifference } from '.';
|
||||
|
||||
describe(useArrayDifference, () => {
|
||||
it('returns the asymmetric difference of two arrays', () => {
|
||||
const list = ref([1, 2, 3, 4, 5]);
|
||||
const values = ref([2, 4]);
|
||||
const diff = useArrayDifference(list, values);
|
||||
expect(diff.value).toEqual([1, 3, 5]);
|
||||
});
|
||||
|
||||
it('returns an empty array when all items are subtracted', () => {
|
||||
const list = ref([1, 2, 3]);
|
||||
const values = ref([1, 2, 3, 4]);
|
||||
const diff = useArrayDifference(list, values);
|
||||
expect(diff.value).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns the full list when values is empty', () => {
|
||||
const list = ref([1, 2, 3]);
|
||||
const values = ref<number[]>([]);
|
||||
const diff = useArrayDifference(list, values);
|
||||
expect(diff.value).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('reacts to changes in the source array', () => {
|
||||
const list = ref([1, 2, 3]);
|
||||
const values = ref([2]);
|
||||
const diff = useArrayDifference(list, values);
|
||||
expect(diff.value).toEqual([1, 3]);
|
||||
|
||||
list.value = [1, 2, 3, 4];
|
||||
expect(diff.value).toEqual([1, 3, 4]);
|
||||
});
|
||||
|
||||
it('reacts to changes in the values array', () => {
|
||||
const list = ref([1, 2, 3]);
|
||||
const values = ref([2]);
|
||||
const diff = useArrayDifference(list, values);
|
||||
expect(diff.value).toEqual([1, 3]);
|
||||
|
||||
values.value = [1, 2];
|
||||
expect(diff.value).toEqual([3]);
|
||||
});
|
||||
|
||||
it('accepts getters as sources', () => {
|
||||
const a = ref(1);
|
||||
const b = ref(2);
|
||||
const diff = useArrayDifference(() => [a.value, b.value, 3], () => [b.value]);
|
||||
expect(diff.value).toEqual([1, 3]);
|
||||
|
||||
b.value = 1;
|
||||
expect(diff.value).toEqual([3]);
|
||||
});
|
||||
|
||||
it('compares by key (positional argument)', () => {
|
||||
const list = ref([{ id: 1 }, { id: 2 }, { id: 3 }]);
|
||||
const values = ref([{ id: 2 }]);
|
||||
const diff = useArrayDifference(list, values, 'id');
|
||||
expect(diff.value).toEqual([{ id: 1 }, { id: 3 }]);
|
||||
});
|
||||
|
||||
it('compares with a custom comparator function', () => {
|
||||
const list = ref([1, 2, 3, 4, 5, 6]);
|
||||
const values = ref([2]);
|
||||
// Treat numbers with the same parity as equal.
|
||||
const diff = useArrayDifference(list, values, (a, b) => a % 2 === b % 2);
|
||||
expect(diff.value).toEqual([1, 3, 5]);
|
||||
});
|
||||
|
||||
it('returns the symmetric difference via options', () => {
|
||||
const a = ref([1, 2, 3]);
|
||||
const b = ref([2, 3, 4]);
|
||||
const diff = useArrayDifference(a, b, { symmetric: true });
|
||||
expect(diff.value).toEqual([1, 4]);
|
||||
});
|
||||
|
||||
it('returns the symmetric difference via the trailing options argument', () => {
|
||||
const a = ref([{ id: 1 }, { id: 2 }]);
|
||||
const b = ref([{ id: 2 }, { id: 3 }]);
|
||||
const diff = useArrayDifference(a, b, 'id', { symmetric: true });
|
||||
expect(diff.value).toEqual([{ id: 1 }, { id: 3 }]);
|
||||
});
|
||||
|
||||
it('accepts a comparator inside the options object', () => {
|
||||
const list = ref([1, 2, 3, 4]);
|
||||
const values = ref([20, 30]);
|
||||
const diff = useArrayDifference(list, values, {
|
||||
comparator: (a, b) => a === b / 10,
|
||||
});
|
||||
expect(diff.value).toEqual([1, 4]);
|
||||
});
|
||||
|
||||
it('reacts to source changes when symmetric', () => {
|
||||
const a = ref([1, 2]);
|
||||
const b = ref([2, 3]);
|
||||
const diff = useArrayDifference(a, b, { symmetric: true });
|
||||
expect(diff.value).toEqual([1, 3]);
|
||||
|
||||
a.value = [1, 2, 3];
|
||||
expect(diff.value).toEqual([1]);
|
||||
});
|
||||
|
||||
it('does not mutate the source arrays in symmetric mode', () => {
|
||||
const a = ref([1, 2]);
|
||||
const b = ref([2, 3]);
|
||||
const diff = useArrayDifference(a, b, { symmetric: true });
|
||||
expect(diff.value).toEqual([1, 3]);
|
||||
expect(a.value).toEqual([1, 2]);
|
||||
expect(b.value).toEqual([2, 3]);
|
||||
});
|
||||
|
||||
it('is SSR-safe: never touches window/document/navigator', () => {
|
||||
// Pure computed wrapper — evaluating it relies on no browser globals.
|
||||
const list = ref([1, 2, 3]);
|
||||
const values = ref([2]);
|
||||
const diff = useArrayDifference(list, values);
|
||||
expect(diff.value).toEqual([1, 3]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { computed, toValue } from 'vue';
|
||||
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
|
||||
import { isObject, isString } from '@robonen/stdlib';
|
||||
|
||||
/**
|
||||
* Comparator deciding whether two array elements are considered equal.
|
||||
*/
|
||||
export type UseArrayDifferenceComparatorFn<T>
|
||||
= (value: T, othVal: T) => boolean;
|
||||
|
||||
export interface UseArrayDifferenceOptions<T> {
|
||||
/**
|
||||
* When `true`, returns the symmetric difference: items present in exactly one
|
||||
* of the two arrays (`list` XOR `values`). When `false`, returns the
|
||||
* asymmetric difference: items in `list` that are not in `values`.
|
||||
*
|
||||
* @see https://en.wikipedia.org/wiki/Symmetric_difference
|
||||
* @default false
|
||||
*/
|
||||
symmetric?: boolean;
|
||||
/**
|
||||
* Custom comparator function, or a key of `T` to compare a single property by.
|
||||
*/
|
||||
comparator?: UseArrayDifferenceComparatorFn<T> | keyof T;
|
||||
}
|
||||
|
||||
export type UseArrayDifferenceReturn<T = any>
|
||||
= ComputedRef<T[]>;
|
||||
|
||||
function isArrayDifferenceOptions<T>(value: unknown): value is UseArrayDifferenceOptions<T> {
|
||||
// isObject matches PLAIN objects only, so comparator functions/keys never reach here.
|
||||
return isObject(value) && ('symmetric' in value || 'comparator' in value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useArrayDifference
|
||||
* @category Array
|
||||
* @description Reactive difference of two arrays. Returns items in `list` that are not in `values` (asymmetric), or items in exactly one array (symmetric). Both arrays may be reactive (refs or getters).
|
||||
*
|
||||
* @param {MaybeRefOrGetter<T[]>} list The source array
|
||||
* @param {MaybeRefOrGetter<T[]>} values The array of values to subtract from `list`
|
||||
* @param {UseArrayDifferenceComparatorFn<T> | keyof T | UseArrayDifferenceOptions<T>} [comparator] A comparator function, a key of `T` to compare by, or an options object with `comparator`/`symmetric`
|
||||
* @param {UseArrayDifferenceOptions<T>} [options] Extra options when `comparator` is a function or key
|
||||
* @returns {UseArrayDifferenceReturn<T>} A computed array of the difference
|
||||
*
|
||||
* @example
|
||||
* const list = ref([1, 2, 3, 4, 5]);
|
||||
* const values = ref([2, 4]);
|
||||
* const diff = useArrayDifference(list, values); // [1, 3, 5]
|
||||
*
|
||||
* @example
|
||||
* const list = ref([{ id: 1 }, { id: 2 }, { id: 3 }]);
|
||||
* const values = ref([{ id: 2 }]);
|
||||
* const diff = useArrayDifference(list, values, 'id'); // [{ id: 1 }, { id: 3 }]
|
||||
*
|
||||
* @example
|
||||
* const a = ref([1, 2, 3]);
|
||||
* const b = ref([2, 3, 4]);
|
||||
* const symmetric = useArrayDifference(a, b, { symmetric: true }); // [1, 4]
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useArrayDifference<T>(
|
||||
list: MaybeRefOrGetter<T[]>,
|
||||
values: MaybeRefOrGetter<T[]>,
|
||||
comparator?: UseArrayDifferenceComparatorFn<T>,
|
||||
options?: UseArrayDifferenceOptions<T>,
|
||||
): UseArrayDifferenceReturn<T>;
|
||||
export function useArrayDifference<T>(
|
||||
list: MaybeRefOrGetter<T[]>,
|
||||
values: MaybeRefOrGetter<T[]>,
|
||||
comparator?: keyof T,
|
||||
options?: UseArrayDifferenceOptions<T>,
|
||||
): UseArrayDifferenceReturn<T>;
|
||||
export function useArrayDifference<T>(
|
||||
list: MaybeRefOrGetter<T[]>,
|
||||
values: MaybeRefOrGetter<T[]>,
|
||||
options?: UseArrayDifferenceOptions<T>,
|
||||
): UseArrayDifferenceReturn<T>;
|
||||
export function useArrayDifference<T>(
|
||||
list: MaybeRefOrGetter<T[]>,
|
||||
values: MaybeRefOrGetter<T[]>,
|
||||
comparator?: UseArrayDifferenceComparatorFn<T> | keyof T | UseArrayDifferenceOptions<T>,
|
||||
options?: UseArrayDifferenceOptions<T>,
|
||||
): UseArrayDifferenceReturn<T> {
|
||||
let symmetric = false;
|
||||
let resolved: UseArrayDifferenceComparatorFn<T> | keyof T | undefined;
|
||||
|
||||
if (isArrayDifferenceOptions<T>(comparator)) {
|
||||
symmetric = comparator.symmetric ?? false;
|
||||
resolved = comparator.comparator;
|
||||
}
|
||||
else {
|
||||
resolved = comparator;
|
||||
symmetric = options?.symmetric ?? false;
|
||||
// An explicit comparator/key in `options` wins over the positional argument.
|
||||
if (options?.comparator !== undefined)
|
||||
resolved = options.comparator;
|
||||
}
|
||||
|
||||
// Resolve the comparator once instead of rebuilding it on every recompute.
|
||||
let compare: UseArrayDifferenceComparatorFn<T>;
|
||||
|
||||
if (isString(resolved) || typeof resolved === 'symbol' || typeof resolved === 'number') {
|
||||
const key = resolved as keyof T;
|
||||
compare = (value, othVal) => value[key] === othVal[key];
|
||||
}
|
||||
else if (typeof resolved === 'function') {
|
||||
compare = resolved;
|
||||
}
|
||||
else {
|
||||
compare = (value, othVal) => value === othVal;
|
||||
}
|
||||
|
||||
return computed(() => {
|
||||
const source = toValue(list);
|
||||
const other = toValue(values);
|
||||
|
||||
// Items in `source` absent from `other`.
|
||||
const diff = source.filter(value => !other.some(othVal => compare(value, othVal)));
|
||||
|
||||
if (!symmetric)
|
||||
return diff;
|
||||
|
||||
// Items in `other` absent from `source`, appended for the symmetric difference.
|
||||
for (const value of other) {
|
||||
if (!source.some(srcVal => compare(value, srcVal)))
|
||||
diff.push(value);
|
||||
}
|
||||
|
||||
return diff;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { useArrayEvery } from '.';
|
||||
|
||||
describe(useArrayEvery, () => {
|
||||
it('returns true when every element passes', () => {
|
||||
const list = ref([1, 2, 3, 4]);
|
||||
const allPositive = useArrayEvery(list, n => n > 0);
|
||||
expect(allPositive.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns false when some element fails', () => {
|
||||
const list = ref([1, -2, 3, 4]);
|
||||
const allPositive = useArrayEvery(list, n => n > 0);
|
||||
expect(allPositive.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('reacts to source array changes', () => {
|
||||
const list = ref([2, 4, 6]);
|
||||
const allEven = useArrayEvery(list, n => n % 2 === 0);
|
||||
expect(allEven.value).toBeTruthy();
|
||||
|
||||
list.value = [2, 4, 5];
|
||||
expect(allEven.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('unwraps reactive items', () => {
|
||||
const items = [ref(2), ref(4), ref(6)];
|
||||
const allEven = useArrayEvery(items, n => n % 2 === 0);
|
||||
expect(allEven.value).toBeTruthy();
|
||||
|
||||
items[1]!.value = 5;
|
||||
expect(allEven.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('accepts a getter as the list source', () => {
|
||||
const a = ref(1);
|
||||
const b = ref(2);
|
||||
const allPositive = useArrayEvery(() => [a.value, b.value], n => n > 0);
|
||||
expect(allPositive.value).toBeTruthy();
|
||||
|
||||
b.value = -1;
|
||||
expect(allPositive.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('passes index and array to the predicate', () => {
|
||||
const list = ref([0, 1, 2, 3]);
|
||||
const matchesIndex = useArrayEvery(list, (element, index, array) => {
|
||||
expect(array).toBe(list.value);
|
||||
return element === index;
|
||||
});
|
||||
expect(matchesIndex.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns true for an empty array (vacuous truth)', () => {
|
||||
const list = ref<number[]>([]);
|
||||
const result = useArrayEvery(list, n => n > 0);
|
||||
expect(result.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('is SSR-safe: never touches window/document/navigator', () => {
|
||||
const list = ref([1, 2, 3]);
|
||||
// Pure computed wrapper — evaluating it relies on no browser globals.
|
||||
const result = useArrayEvery(list, n => n > 0);
|
||||
expect(result.value).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { computed, toValue } from 'vue';
|
||||
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
|
||||
|
||||
export type UseArrayEveryReturn = ComputedRef<boolean>;
|
||||
|
||||
/**
|
||||
* @name useArrayEvery
|
||||
* @category Array
|
||||
* @description Reactive `Array.prototype.every`. The source array and its items may be reactive.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
|
||||
* @param {(element: T, index: number, array: MaybeRefOrGetter<T>[]) => unknown} fn Predicate to test each element
|
||||
* @returns {UseArrayEveryReturn} A computed boolean that is `true` if `fn` returns a truthy value for every element, otherwise `false`
|
||||
*
|
||||
* @example
|
||||
* const list = ref([1, 2, 3, 4]);
|
||||
* const allPositive = useArrayEvery(list, n => n > 0); // true
|
||||
*
|
||||
* @example
|
||||
* const items = [ref(2), ref(4), ref(6)];
|
||||
* const allEven = useArrayEvery(items, n => n % 2 === 0); // true
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useArrayEvery<T>(
|
||||
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
|
||||
fn: (element: T, index: number, array: Array<MaybeRefOrGetter<T>>) => unknown,
|
||||
): UseArrayEveryReturn {
|
||||
return computed(() => toValue(list).every((element, index, array) => fn(toValue(element), index, array)));
|
||||
}
|
||||
+1
-1
@@ -3,7 +3,7 @@ import type { ComputedRef, MaybeRefOrGetter } from 'vue';
|
||||
|
||||
/**
|
||||
* @name useArrayFilter
|
||||
* @category Reactivity
|
||||
* @category Array
|
||||
* @description Reactive `Array.prototype.filter`.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
|
||||
+1
-1
@@ -3,7 +3,7 @@ import type { ComputedRef, MaybeRefOrGetter } from 'vue';
|
||||
|
||||
/**
|
||||
* @name useArrayFind
|
||||
* @category Reactivity
|
||||
* @category Array
|
||||
* @description Reactive `Array.prototype.find`.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { useArrayFindIndex } from '.';
|
||||
|
||||
describe(useArrayFindIndex, () => {
|
||||
it('finds the index reactively', () => {
|
||||
const list = ref([1, 2, 3]);
|
||||
const index = useArrayFindIndex(list, n => n > 1);
|
||||
expect(index.value).toBe(1);
|
||||
|
||||
list.value = [10, 20];
|
||||
expect(index.value).toBe(0);
|
||||
});
|
||||
|
||||
it('returns -1 when nothing matches', () => {
|
||||
const index = useArrayFindIndex(ref([1, 2]), n => n > 5);
|
||||
expect(index.value).toBe(-1);
|
||||
});
|
||||
|
||||
it('returns -1 for an empty array', () => {
|
||||
const index = useArrayFindIndex(ref<number[]>([]), () => true);
|
||||
expect(index.value).toBe(-1);
|
||||
});
|
||||
|
||||
it('passes element, index and the resolved array to the predicate', () => {
|
||||
const calls: Array<[number, number, number[]]> = [];
|
||||
const index = useArrayFindIndex(ref([5, 6, 7]), (element, idx, array) => {
|
||||
calls.push([element, idx, array]);
|
||||
return element === 7;
|
||||
});
|
||||
|
||||
expect(index.value).toBe(2);
|
||||
expect(calls).toEqual([
|
||||
[5, 0, [5, 6, 7]],
|
||||
[6, 1, [5, 6, 7]],
|
||||
[7, 2, [5, 6, 7]],
|
||||
]);
|
||||
});
|
||||
|
||||
it('unwraps reactive items inside the list', () => {
|
||||
const a = ref(1);
|
||||
const b = ref(2);
|
||||
const index = useArrayFindIndex([a, b], n => n === 2);
|
||||
expect(index.value).toBe(1);
|
||||
|
||||
b.value = 0;
|
||||
a.value = 0;
|
||||
expect(index.value).toBe(-1);
|
||||
});
|
||||
|
||||
it('accepts a getter as the source list', () => {
|
||||
const source = ref([3, 4, 5]);
|
||||
const index = useArrayFindIndex(() => source.value, n => n % 2 === 0);
|
||||
expect(index.value).toBe(1);
|
||||
|
||||
source.value = [1, 3, 5];
|
||||
expect(index.value).toBe(-1);
|
||||
});
|
||||
|
||||
it('accepts a plain (non-reactive) array', () => {
|
||||
const index = useArrayFindIndex([10, 20, 30], n => n === 30);
|
||||
expect(index.value).toBe(2);
|
||||
});
|
||||
|
||||
it('returns the FIRST matching index', () => {
|
||||
const index = useArrayFindIndex(ref([2, 4, 6, 8]), n => n % 2 === 0);
|
||||
expect(index.value).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { computed, toValue } from 'vue';
|
||||
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
|
||||
|
||||
export type UseArrayFindIndexReturn = ComputedRef<number>;
|
||||
|
||||
/**
|
||||
* @name useArrayFindIndex
|
||||
* @category Array
|
||||
* @description Reactive `Array.prototype.findIndex`.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
|
||||
* @param {(element: T, index: number, array: T[]) => unknown} fn Predicate testing each element
|
||||
* @returns {UseArrayFindIndexReturn} The index of the first matching element, or `-1` if none match
|
||||
*
|
||||
* @example
|
||||
* const list = ref([1, 2, 3]);
|
||||
* const index = useArrayFindIndex(list, n => n > 1); // 1
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useArrayFindIndex<T>(
|
||||
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
|
||||
fn: (element: T, index: number, array: T[]) => unknown,
|
||||
): UseArrayFindIndexReturn {
|
||||
return computed(() => {
|
||||
const resolved = toValue(list).map(item => toValue(item));
|
||||
return resolved.findIndex(fn);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { useArrayFindLast } from '.';
|
||||
|
||||
describe(useArrayFindLast, () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('finds the last matching element reactively', () => {
|
||||
const list = ref([1, 2, 3, 4]);
|
||||
const found = useArrayFindLast(list, n => n % 2 === 0);
|
||||
expect(found.value).toBe(4);
|
||||
|
||||
list.value = [10, 20, 21];
|
||||
expect(found.value).toBe(20);
|
||||
});
|
||||
|
||||
it('returns undefined when nothing matches', () => {
|
||||
const found = useArrayFindLast(ref([1, 2]), n => n > 5);
|
||||
expect(found.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for an empty array', () => {
|
||||
const found = useArrayFindLast(ref<number[]>([]), () => true);
|
||||
expect(found.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the LAST matching element', () => {
|
||||
const found = useArrayFindLast(ref([2, 4, 6, 8]), n => n % 2 === 0);
|
||||
expect(found.value).toBe(8);
|
||||
});
|
||||
|
||||
it('passes element, index and the resolved array to the predicate', () => {
|
||||
const calls: Array<[number, number, number[]]> = [];
|
||||
const found = useArrayFindLast(ref([5, 6, 7]), (element, idx, array) => {
|
||||
calls.push([element, idx, array]);
|
||||
return element === 7;
|
||||
});
|
||||
|
||||
expect(found.value).toBe(7);
|
||||
// findLast iterates from the end; the match at index 2 stops it immediately.
|
||||
expect(calls).toEqual([
|
||||
[7, 2, [5, 6, 7]],
|
||||
]);
|
||||
});
|
||||
|
||||
it('unwraps reactive items inside the list', () => {
|
||||
const a = ref(1);
|
||||
const b = ref(2);
|
||||
const found = useArrayFindLast([a, b], n => n < 5);
|
||||
expect(found.value).toBe(2);
|
||||
|
||||
b.value = 9;
|
||||
expect(found.value).toBe(1);
|
||||
});
|
||||
|
||||
it('accepts a getter as the source list', () => {
|
||||
const source = ref([3, 4, 5, 6]);
|
||||
const found = useArrayFindLast(() => source.value, n => n % 2 === 0);
|
||||
expect(found.value).toBe(6);
|
||||
|
||||
source.value = [1, 3, 5];
|
||||
expect(found.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts a plain (non-reactive) array', () => {
|
||||
const found = useArrayFindLast([10, 20, 30], n => n < 25);
|
||||
expect(found.value).toBe(20);
|
||||
});
|
||||
|
||||
it('works via the polyfill when Array.prototype.findLast is unavailable', () => {
|
||||
const native = Array.prototype.findLast;
|
||||
try {
|
||||
// Simulate a runtime older than ES2023.
|
||||
(Array.prototype as { findLast?: unknown }).findLast = undefined;
|
||||
vi.resetModules();
|
||||
// The presence check runs at module import time, so re-import here.
|
||||
return import('.').then(({ useArrayFindLast: useArrayFindLastFresh }) => {
|
||||
const found = useArrayFindLastFresh(ref([1, 2, 3, 4]), n => n % 2 === 0);
|
||||
expect(found.value).toBe(4);
|
||||
|
||||
const none = useArrayFindLastFresh(ref([1, 3, 5]), n => n % 2 === 0);
|
||||
expect(none.value).toBeUndefined();
|
||||
});
|
||||
}
|
||||
finally {
|
||||
(Array.prototype as { findLast?: unknown }).findLast = native;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { computed, toValue } from 'vue';
|
||||
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
|
||||
|
||||
export type UseArrayFindLastReturn<T = unknown>
|
||||
= ComputedRef<T | undefined>;
|
||||
|
||||
/**
|
||||
* `Array.prototype.findLast` polyfill for runtimes older than ES2023.
|
||||
*/
|
||||
function findLast<T>(
|
||||
array: T[],
|
||||
fn: (element: T, index: number, array: T[]) => unknown,
|
||||
): T | undefined {
|
||||
let index = array.length;
|
||||
while (index-- > 0) {
|
||||
const element = array[index]!;
|
||||
if (fn(element, index, array))
|
||||
return element;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const hasNativeFindLast = typeof Array.prototype.findLast === 'function';
|
||||
|
||||
/**
|
||||
* @name useArrayFindLast
|
||||
* @category Array
|
||||
* @description Reactive `Array.prototype.findLast`.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
|
||||
* @param {(element: T, index: number, array: T[]) => unknown} fn Predicate testing each element
|
||||
* @returns {UseArrayFindLastReturn<T>} The last matching element, or `undefined` if none match
|
||||
*
|
||||
* @example
|
||||
* const list = ref([1, 2, 3, 4]);
|
||||
* const found = useArrayFindLast(list, n => n % 2 === 0); // 4
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useArrayFindLast<T>(
|
||||
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
|
||||
fn: (element: T, index: number, array: T[]) => unknown,
|
||||
): UseArrayFindLastReturn<T> {
|
||||
return computed(() => {
|
||||
const resolved = toValue(list).map(item => toValue(item));
|
||||
return hasNativeFindLast ? resolved.findLast(fn) : findLast(resolved, fn);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { useArrayIncludes } from '.';
|
||||
|
||||
describe(useArrayIncludes, () => {
|
||||
it('returns true when the value is present', () => {
|
||||
const list = ref([1, 2, 3, 4]);
|
||||
const has = useArrayIncludes(list, 3);
|
||||
expect(has.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns false when the value is absent', () => {
|
||||
const list = ref([1, 2, 3, 4]);
|
||||
const has = useArrayIncludes(list, 5);
|
||||
expect(has.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('returns false for an empty array', () => {
|
||||
const list = ref<number[]>([]);
|
||||
const has = useArrayIncludes(list, 1);
|
||||
expect(has.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('updates reactively when the source array changes', () => {
|
||||
const list = ref([1, 2, 3]);
|
||||
const has = useArrayIncludes(list, 4);
|
||||
expect(has.value).toBeFalsy();
|
||||
|
||||
list.value = [1, 4, 5];
|
||||
expect(has.value).toBeTruthy();
|
||||
|
||||
list.value = [1, 2];
|
||||
expect(has.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('updates reactively when the searched value changes', () => {
|
||||
const list = ref([1, 2, 3]);
|
||||
const target = ref(2);
|
||||
const has = useArrayIncludes(list, target);
|
||||
expect(has.value).toBeTruthy();
|
||||
|
||||
target.value = 9;
|
||||
expect(has.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('unwraps reactive items', () => {
|
||||
const list = [ref(1), ref(2), ref(3)];
|
||||
const has = useArrayIncludes(list, 2);
|
||||
expect(has.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('reacts to changes in reactive items', () => {
|
||||
const a = ref(1);
|
||||
const b = ref(2);
|
||||
const has = useArrayIncludes([a, b], 9);
|
||||
expect(has.value).toBeFalsy();
|
||||
|
||||
b.value = 9;
|
||||
expect(has.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('accepts a getter as the source', () => {
|
||||
const source = ref([1, 2, 3]);
|
||||
const has = useArrayIncludes(() => source.value, 3);
|
||||
expect(has.value).toBeTruthy();
|
||||
|
||||
source.value = [1, 2];
|
||||
expect(has.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('supports a custom comparator function', () => {
|
||||
const list = ref([{ id: 1 }, { id: 2 }, { id: 3 }]);
|
||||
const has = useArrayIncludes(list, 2, (element, value) => element.id === value);
|
||||
expect(has.value).toBeTruthy();
|
||||
|
||||
const missing = useArrayIncludes(list, 9, (element, value) => element.id === value);
|
||||
expect(missing.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('passes index and array to the comparator', () => {
|
||||
const list = ref(['a', 'b', 'c']);
|
||||
const calls: Array<[string, string, number, number]> = [];
|
||||
const has = useArrayIncludes(list, 'z', (element, value, index, array) => {
|
||||
calls.push([element, value, index, array.length]);
|
||||
return false;
|
||||
});
|
||||
expect(has.value).toBeFalsy();
|
||||
expect(calls).toEqual([
|
||||
['a', 'z', 0, 3],
|
||||
['b', 'z', 1, 3],
|
||||
['c', 'z', 2, 3],
|
||||
]);
|
||||
});
|
||||
|
||||
it('supports a key of T as the comparator', () => {
|
||||
const list = ref([{ id: 1 }, { id: 2 }, { id: 3 }]);
|
||||
const has = useArrayIncludes(list, 2, 'id');
|
||||
expect(has.value).toBeTruthy();
|
||||
|
||||
const missing = useArrayIncludes(list, 9, 'id');
|
||||
expect(missing.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('reacts to changes when comparing by key', () => {
|
||||
const list = ref([{ id: 1 }, { id: 2 }]);
|
||||
const target = ref(2);
|
||||
const has = useArrayIncludes(list, target, 'id');
|
||||
expect(has.value).toBeTruthy();
|
||||
|
||||
target.value = 5;
|
||||
expect(has.value).toBeFalsy();
|
||||
|
||||
list.value = [{ id: 5 }];
|
||||
expect(has.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('honors a positive fromIndex', () => {
|
||||
const list = ref(['a', 'b', 'a']);
|
||||
const fromZero = useArrayIncludes(list, 'a', { fromIndex: 0 });
|
||||
expect(fromZero.value).toBeTruthy();
|
||||
|
||||
const fromTwo = useArrayIncludes(list, 'a', { fromIndex: 2 });
|
||||
expect(fromTwo.value).toBeTruthy();
|
||||
|
||||
const fromThree = useArrayIncludes(list, 'a', { fromIndex: 3 });
|
||||
expect(fromThree.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('honors a negative fromIndex like Array.includes', () => {
|
||||
const list = ref([1, 2, 3, 4, 5]);
|
||||
const lastTwo = useArrayIncludes(list, 3, { fromIndex: -2 });
|
||||
expect(lastTwo.value).toBeFalsy();
|
||||
|
||||
const lastThree = useArrayIncludes(list, 3, { fromIndex: -3 });
|
||||
expect(lastThree.value).toBeTruthy();
|
||||
|
||||
// Negative index beyond the start clamps to 0.
|
||||
const wayBack = useArrayIncludes(list, 1, { fromIndex: -100 });
|
||||
expect(wayBack.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('combines comparator and fromIndex in the options object', () => {
|
||||
const list = ref([{ id: 1 }, { id: 2 }, { id: 1 }]);
|
||||
const has = useArrayIncludes(list, 1, {
|
||||
comparator: 'id',
|
||||
fromIndex: 1,
|
||||
});
|
||||
expect(has.value).toBeTruthy();
|
||||
|
||||
const missing = useArrayIncludes(list, 2, {
|
||||
comparator: 'id',
|
||||
fromIndex: 2,
|
||||
});
|
||||
expect(missing.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('uses strict equality by default', () => {
|
||||
const list = ref<Array<number | string>>([1, 2, 3]);
|
||||
const has = useArrayIncludes(list, '2');
|
||||
expect(has.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('matches the searched value when it is a reactive getter', () => {
|
||||
const list = ref([10, 20, 30]);
|
||||
const has = useArrayIncludes(list, () => 20);
|
||||
expect(has.value).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { computed, toValue } from 'vue';
|
||||
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
|
||||
import { isObject, isString } from '@robonen/stdlib';
|
||||
|
||||
/**
|
||||
* Comparator deciding whether an array element equals the searched value.
|
||||
*/
|
||||
export type UseArrayIncludesComparatorFn<T, V>
|
||||
= (element: T, value: V, index: number, array: T[]) => boolean;
|
||||
|
||||
export interface UseArrayIncludesOptions<T, V> {
|
||||
/**
|
||||
* Index at which to start searching (negative counts from the end, like `Array.prototype.includes`).
|
||||
*
|
||||
* @default 0
|
||||
*/
|
||||
fromIndex?: number;
|
||||
/**
|
||||
* Custom comparator function, or a key of `T` to compare a single property by.
|
||||
*/
|
||||
comparator?: UseArrayIncludesComparatorFn<T, V> | keyof T;
|
||||
}
|
||||
|
||||
export type UseArrayIncludesReturn = ComputedRef<boolean>;
|
||||
|
||||
function isArrayIncludesOptions<T, V>(value: unknown): value is UseArrayIncludesOptions<T, V> {
|
||||
// isObject matches PLAIN objects only, so functions/keys never reach here.
|
||||
return isObject(value) && ('fromIndex' in value || 'comparator' in value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useArrayIncludes
|
||||
* @category Array
|
||||
* @description Reactive `Array.prototype.includes` with an optional comparator and `fromIndex`. The source array and its items may be reactive.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
|
||||
* @param {MaybeRefOrGetter<V>} value The value to search for (may be reactive)
|
||||
* @param {UseArrayIncludesComparatorFn<T, V> | keyof T | UseArrayIncludesOptions<T, V>} [comparator] A comparator function, a key of `T` to compare by, or an options object with `comparator`/`fromIndex`
|
||||
* @returns {UseArrayIncludesReturn} A computed boolean that is `true` when the value is found
|
||||
*
|
||||
* @example
|
||||
* const list = ref([1, 2, 3, 4]);
|
||||
* const hasThree = useArrayIncludes(list, 3); // true
|
||||
*
|
||||
* @example
|
||||
* const list = ref([{ id: 1 }, { id: 2 }]);
|
||||
* const hasTwo = useArrayIncludes(list, 2, 'id'); // compare by key
|
||||
*
|
||||
* @example
|
||||
* const list = ref(['a', 'b', 'a']);
|
||||
* const fromSecond = useArrayIncludes(list, 'a', { fromIndex: 1 }); // true
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useArrayIncludes<T, V = T>(
|
||||
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
|
||||
value: MaybeRefOrGetter<V>,
|
||||
comparator?: UseArrayIncludesComparatorFn<T, V>,
|
||||
): UseArrayIncludesReturn;
|
||||
export function useArrayIncludes<T, V = T>(
|
||||
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
|
||||
value: MaybeRefOrGetter<V>,
|
||||
comparator?: keyof T,
|
||||
): UseArrayIncludesReturn;
|
||||
export function useArrayIncludes<T, V = T>(
|
||||
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
|
||||
value: MaybeRefOrGetter<V>,
|
||||
options?: UseArrayIncludesOptions<T, V>,
|
||||
): UseArrayIncludesReturn;
|
||||
export function useArrayIncludes<T, V = T>(
|
||||
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
|
||||
value: MaybeRefOrGetter<V>,
|
||||
comparator?: UseArrayIncludesComparatorFn<T, V> | keyof T | UseArrayIncludesOptions<T, V>,
|
||||
): UseArrayIncludesReturn {
|
||||
let fromIndex = 0;
|
||||
let resolved = comparator;
|
||||
|
||||
if (isArrayIncludesOptions<T, V>(resolved)) {
|
||||
fromIndex = resolved.fromIndex ?? 0;
|
||||
resolved = resolved.comparator;
|
||||
}
|
||||
|
||||
// Resolve the comparator once instead of on every recompute.
|
||||
let compare: UseArrayIncludesComparatorFn<T, V>;
|
||||
|
||||
if (isString(resolved) || typeof resolved === 'symbol' || typeof resolved === 'number') {
|
||||
const key = resolved as keyof T;
|
||||
compare = (element, searched) => element[key] === (searched as unknown);
|
||||
}
|
||||
else if (typeof resolved === 'function') {
|
||||
compare = resolved;
|
||||
}
|
||||
else {
|
||||
compare = (element, searched) => (element as unknown) === searched;
|
||||
}
|
||||
|
||||
return computed(() => {
|
||||
const array = toValue(list);
|
||||
const searched = toValue(value);
|
||||
const length = array.length;
|
||||
|
||||
// Resolve a negative / out-of-range fromIndex the same way Array.includes does.
|
||||
let start = fromIndex < 0 ? length + fromIndex : fromIndex;
|
||||
if (start < 0)
|
||||
start = 0;
|
||||
|
||||
for (let index = start; index < length; index++) {
|
||||
// `index` is bounded by `length`; `!` drops the index-access undefined.
|
||||
if (compare(toValue(array[index]!), searched, index, array as T[]))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { useArrayJoin } from '.';
|
||||
|
||||
describe(useArrayJoin, () => {
|
||||
it('joins with the default comma separator', () => {
|
||||
const list = ref(['a', 'b', 'c']);
|
||||
const joined = useArrayJoin(list);
|
||||
expect(joined.value).toBe('a,b,c');
|
||||
});
|
||||
|
||||
it('joins with a static separator', () => {
|
||||
const list = ref(['a', 'b', 'c']);
|
||||
const joined = useArrayJoin(list, '-');
|
||||
expect(joined.value).toBe('a-b-c');
|
||||
});
|
||||
|
||||
it('recomputes when the source array changes', () => {
|
||||
const list = ref(['a', 'b']);
|
||||
const joined = useArrayJoin(list, '/');
|
||||
expect(joined.value).toBe('a/b');
|
||||
|
||||
list.value = ['x', 'y', 'z'];
|
||||
expect(joined.value).toBe('x/y/z');
|
||||
});
|
||||
|
||||
it('reacts to a reactive separator', () => {
|
||||
const list = ref(['a', 'b', 'c']);
|
||||
const sep = ref('-');
|
||||
const joined = useArrayJoin(list, sep);
|
||||
expect(joined.value).toBe('a-b-c');
|
||||
|
||||
sep.value = ' | ';
|
||||
expect(joined.value).toBe('a | b | c');
|
||||
});
|
||||
|
||||
it('unwraps reactive items', () => {
|
||||
const list = [ref('a'), ref('b'), ref('c')];
|
||||
const joined = useArrayJoin(list, '-');
|
||||
expect(joined.value).toBe('a-b-c');
|
||||
});
|
||||
|
||||
it('reacts to changes in reactive items', () => {
|
||||
const a = ref('a');
|
||||
const list = [a, ref('b')];
|
||||
const joined = useArrayJoin(list, '-');
|
||||
expect(joined.value).toBe('a-b');
|
||||
|
||||
a.value = 'z';
|
||||
expect(joined.value).toBe('z-b');
|
||||
});
|
||||
|
||||
it('accepts a getter as the source list', () => {
|
||||
const a = ref('a');
|
||||
const b = ref('b');
|
||||
const joined = useArrayJoin(() => [a.value, b.value], '-');
|
||||
expect(joined.value).toBe('a-b');
|
||||
|
||||
a.value = 'z';
|
||||
expect(joined.value).toBe('z-b');
|
||||
});
|
||||
|
||||
it('returns an empty string for an empty array', () => {
|
||||
const list = ref<string[]>([]);
|
||||
const joined = useArrayJoin(list, '-');
|
||||
expect(joined.value).toBe('');
|
||||
});
|
||||
|
||||
it('returns the single element with no separator applied', () => {
|
||||
const list = ref(['only']);
|
||||
const joined = useArrayJoin(list, '-');
|
||||
expect(joined.value).toBe('only');
|
||||
});
|
||||
|
||||
it('stringifies non-string elements like native join', () => {
|
||||
const list = ref([1, 2, 3]);
|
||||
const joined = useArrayJoin(list, '+');
|
||||
expect(joined.value).toBe('1+2+3');
|
||||
});
|
||||
|
||||
it('renders null and undefined as empty strings like native join', () => {
|
||||
const list = ref([null, 'a', undefined, 'b']);
|
||||
const joined = useArrayJoin(list, ',');
|
||||
expect(joined.value).toBe(',a,,b');
|
||||
});
|
||||
|
||||
it('treats an empty-string separator as concatenation', () => {
|
||||
const list = ref(['a', 'b', 'c']);
|
||||
const joined = useArrayJoin(list, '');
|
||||
expect(joined.value).toBe('abc');
|
||||
});
|
||||
|
||||
it('joins a getter list of reactive items', () => {
|
||||
const a = ref('a');
|
||||
const b = ref('b');
|
||||
const list = ref([a, b]);
|
||||
const joined = useArrayJoin(() => list.value, '-');
|
||||
expect(joined.value).toBe('a-b');
|
||||
|
||||
b.value = 'z';
|
||||
expect(joined.value).toBe('a-z');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { computed, toValue } from 'vue';
|
||||
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
|
||||
|
||||
export type UseArrayJoinReturn = ComputedRef<string>;
|
||||
|
||||
/**
|
||||
* @name useArrayJoin
|
||||
* @category Array
|
||||
* @description Reactive `Array.prototype.join`, with an optional reactive separator.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<MaybeRefOrGetter<unknown>[]>} list The source array (items can be reactive)
|
||||
* @param {MaybeRefOrGetter<string>} [separator] A reactive separator placed between adjacent elements (defaults to `,`)
|
||||
* @returns {UseArrayJoinReturn} A computed string of all elements joined; empty string when the array is empty
|
||||
*
|
||||
* @example
|
||||
* const list = ref(['a', 'b', 'c']);
|
||||
* const sep = ref('-');
|
||||
* const joined = useArrayJoin(list, sep); // 'a-b-c'
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useArrayJoin(
|
||||
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<unknown>>>,
|
||||
separator?: MaybeRefOrGetter<string>,
|
||||
): UseArrayJoinReturn {
|
||||
return computed(() => {
|
||||
const resolved = toValue(list);
|
||||
|
||||
// `Array.prototype.join` already stringifies each element, but resolving
|
||||
// reactive items first lets the computed track per-item ref dependencies.
|
||||
let needsUnwrap = false;
|
||||
for (const item of resolved) {
|
||||
if (typeof item === 'function' || (typeof item === 'object' && item !== null && 'value' in item)) {
|
||||
needsUnwrap = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const source = needsUnwrap ? resolved.map(item => toValue(item)) : resolved;
|
||||
return source.join(toValue(separator));
|
||||
});
|
||||
}
|
||||
+1
-1
@@ -3,7 +3,7 @@ import type { ComputedRef, MaybeRefOrGetter } from 'vue';
|
||||
|
||||
/**
|
||||
* @name useArrayMap
|
||||
* @category Reactivity
|
||||
* @category Array
|
||||
* @description Reactive `Array.prototype.map`.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { useArrayReduce } from '.';
|
||||
|
||||
describe(useArrayReduce, () => {
|
||||
it('reduces without an initial value', () => {
|
||||
const list = ref([1, 2, 3, 4]);
|
||||
const sum = useArrayReduce(list, (acc, n) => acc + n);
|
||||
expect(sum.value).toBe(10);
|
||||
});
|
||||
|
||||
it('reduces with an initial value', () => {
|
||||
const list = ref([1, 2, 3, 4]);
|
||||
const sum = useArrayReduce(list, (acc, n) => acc + n, 100);
|
||||
expect(sum.value).toBe(110);
|
||||
});
|
||||
|
||||
it('recomputes when the source array changes', () => {
|
||||
const list = ref([1, 2, 3]);
|
||||
const sum = useArrayReduce(list, (acc, n) => acc + n, 0);
|
||||
expect(sum.value).toBe(6);
|
||||
|
||||
list.value = [10, 20];
|
||||
expect(sum.value).toBe(30);
|
||||
});
|
||||
|
||||
it('unwraps reactive items', () => {
|
||||
const list = [ref(1), ref(2), ref(3)];
|
||||
const sum = useArrayReduce(list, (acc, n) => acc + n, 0);
|
||||
expect(sum.value).toBe(6);
|
||||
});
|
||||
|
||||
it('reacts to a reactive initial value', () => {
|
||||
const list = ref([1, 2, 3]);
|
||||
const seed = ref(10);
|
||||
const sum = useArrayReduce(list, (acc, n) => acc + n, seed);
|
||||
expect(sum.value).toBe(16);
|
||||
|
||||
seed.value = 100;
|
||||
expect(sum.value).toBe(106);
|
||||
});
|
||||
|
||||
it('passes the current index to the reducer', () => {
|
||||
const list = ref(['a', 'b', 'c']);
|
||||
const indexed = useArrayReduce(
|
||||
list,
|
||||
(acc, value, index) => `${acc}${index}:${value};`,
|
||||
'',
|
||||
);
|
||||
expect(indexed.value).toBe('0:a;1:b;2:c;');
|
||||
});
|
||||
|
||||
it('supports a different accumulator type via initial value', () => {
|
||||
const list = ref(['a', 'b', 'a', 'c', 'b']);
|
||||
const counts = useArrayReduce(
|
||||
list,
|
||||
(acc: Record<string, number>, key) => {
|
||||
acc[key] = (acc[key] ?? 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
() => ({}) as Record<string, number>,
|
||||
);
|
||||
expect(counts.value).toEqual({ a: 2, b: 2, c: 1 });
|
||||
});
|
||||
|
||||
it('treats undefined as a valid initial value (not a missing seed)', () => {
|
||||
const list = ref([1, 2]);
|
||||
// With a real seed of `undefined`, the reducer runs for every element.
|
||||
const calls: Array<[unknown, number]> = [];
|
||||
const result = useArrayReduce<number, number | undefined>(
|
||||
list,
|
||||
(acc, n) => {
|
||||
calls.push([acc, n]);
|
||||
return n;
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
expect(result.value).toBe(2);
|
||||
expect(calls).toEqual([[undefined, 1], [1, 2]]);
|
||||
});
|
||||
|
||||
it('throws on an empty array with no initial value (native reduce semantics)', () => {
|
||||
const list = ref<number[]>([]);
|
||||
const sum = useArrayReduce(list, (acc, n) => acc + n);
|
||||
expect(() => sum.value).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('returns the initial value for an empty array', () => {
|
||||
const list = ref<number[]>([]);
|
||||
const sum = useArrayReduce(list, (acc, n) => acc + n, 42);
|
||||
expect(sum.value).toBe(42);
|
||||
});
|
||||
|
||||
it('accepts a getter as the source list', () => {
|
||||
const a = ref(1);
|
||||
const b = ref(2);
|
||||
const product = useArrayReduce(() => [a.value, b.value], (acc, n) => acc * n, 1);
|
||||
expect(product.value).toBe(2);
|
||||
|
||||
a.value = 5;
|
||||
expect(product.value).toBe(10);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { computed, toValue } from 'vue';
|
||||
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
|
||||
|
||||
export type UseArrayReducer<PV, CV, R>
|
||||
= (accumulator: PV, currentValue: CV, currentIndex: number) => R;
|
||||
|
||||
export type UseArrayReduceReturn<T> = ComputedRef<T>;
|
||||
|
||||
/**
|
||||
* @name useArrayReduce
|
||||
* @category Array
|
||||
* @description Reactive `Array.prototype.reduce`, with an optional initial value.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
|
||||
* @param {UseArrayReducer<T, T, T>} reducer A reducer callback applied to each element
|
||||
* @returns {UseArrayReduceReturn<T>} The reduced value
|
||||
*
|
||||
* @example
|
||||
* const list = ref([1, 2, 3, 4]);
|
||||
* const sum = useArrayReduce(list, (acc, n) => acc + n); // 10
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useArrayReduce<T>(
|
||||
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
|
||||
reducer: UseArrayReducer<T, T, T>,
|
||||
): UseArrayReduceReturn<T>;
|
||||
|
||||
/**
|
||||
* @name useArrayReduce
|
||||
* @category Array
|
||||
* @description Reactive `Array.prototype.reduce`, with an optional initial value.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
|
||||
* @param {UseArrayReducer<U, T, U>} reducer A reducer callback applied to each element
|
||||
* @param {MaybeRefOrGetter<U>} initialValue A reactive value to seed the accumulator with
|
||||
* @returns {UseArrayReduceReturn<U>} The reduced value
|
||||
*
|
||||
* @example
|
||||
* const list = ref([1, 2, 3, 4]);
|
||||
* const sum = useArrayReduce(list, (acc, n) => acc + n, 100); // 110
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useArrayReduce<T, U>(
|
||||
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
|
||||
reducer: UseArrayReducer<U, T, U>,
|
||||
initialValue: MaybeRefOrGetter<U>,
|
||||
): UseArrayReduceReturn<U>;
|
||||
|
||||
export function useArrayReduce<T, U>(
|
||||
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
|
||||
reducer: UseArrayReducer<U, T, U>,
|
||||
initialValue?: MaybeRefOrGetter<U>,
|
||||
): UseArrayReduceReturn<U> {
|
||||
const step = (
|
||||
accumulator: U,
|
||||
current: MaybeRefOrGetter<T>,
|
||||
index: number,
|
||||
): U => reducer(accumulator, toValue(current), index);
|
||||
|
||||
// Capture presence here (arguments.length, not a default value) so that an
|
||||
// explicitly-passed `undefined` is still honoured as a real initial value.
|
||||
const hasInitial = arguments.length >= 3;
|
||||
|
||||
return computed(() => {
|
||||
const resolved = toValue(list);
|
||||
|
||||
return hasInitial
|
||||
? resolved.reduce(step, toValue(initialValue as MaybeRefOrGetter<U>))
|
||||
: (resolved as unknown as U[]).reduce(step as unknown as (a: U, c: U, i: number) => U);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { useArraySome } from '.';
|
||||
|
||||
describe(useArraySome, () => {
|
||||
it('returns true when any element matches', () => {
|
||||
const list = ref([1, 2, 3, 4]);
|
||||
const hasEven = useArraySome(list, n => n % 2 === 0);
|
||||
expect(hasEven.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns false when no element matches', () => {
|
||||
const list = ref([1, 3, 5, 7]);
|
||||
const hasEven = useArraySome(list, n => n % 2 === 0);
|
||||
expect(hasEven.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('returns false for an empty array', () => {
|
||||
const list = ref<number[]>([]);
|
||||
const result = useArraySome(list, () => true);
|
||||
expect(result.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('updates reactively when the source array changes', () => {
|
||||
const list = ref([1, 3, 5]);
|
||||
const hasEven = useArraySome(list, n => n % 2 === 0);
|
||||
expect(hasEven.value).toBeFalsy();
|
||||
|
||||
list.value = [1, 2, 5];
|
||||
expect(hasEven.value).toBeTruthy();
|
||||
|
||||
list.value = [7, 9];
|
||||
expect(hasEven.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('unwraps reactive items', () => {
|
||||
const list = [ref(1), ref(3), ref(4)];
|
||||
const hasEven = useArraySome(list, n => n % 2 === 0);
|
||||
expect(hasEven.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('reacts to changes in reactive items', () => {
|
||||
const a = ref(1);
|
||||
const b = ref(3);
|
||||
const list = [a, b];
|
||||
const hasEven = useArraySome(list, n => n % 2 === 0);
|
||||
expect(hasEven.value).toBeFalsy();
|
||||
|
||||
b.value = 4;
|
||||
expect(hasEven.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('accepts a getter as the source', () => {
|
||||
const source = ref([1, 2, 3]);
|
||||
const hasThree = useArraySome(() => source.value, n => n === 3);
|
||||
expect(hasThree.value).toBeTruthy();
|
||||
|
||||
source.value = [1, 2];
|
||||
expect(hasThree.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('passes index and array to the predicate', () => {
|
||||
const list = ref(['a', 'b', 'c']);
|
||||
const calls: Array<[string, number, number]> = [];
|
||||
const result = useArraySome(list, (element, index, array) => {
|
||||
calls.push([element, index, array.length]);
|
||||
return false;
|
||||
});
|
||||
expect(result.value).toBeFalsy();
|
||||
expect(calls).toEqual([['a', 0, 3], ['b', 1, 3], ['c', 2, 3]]);
|
||||
});
|
||||
|
||||
it('short-circuits on the first truthy result', () => {
|
||||
const list = ref([1, 2, 3, 4]);
|
||||
let visited = 0;
|
||||
const result = useArraySome(list, (n) => {
|
||||
visited++;
|
||||
return n === 2;
|
||||
});
|
||||
expect(result.value).toBeTruthy();
|
||||
expect(visited).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { computed, toValue } from 'vue';
|
||||
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
|
||||
|
||||
export type UseArraySomeReturn = ComputedRef<boolean>;
|
||||
|
||||
/**
|
||||
* @name useArraySome
|
||||
* @category Array
|
||||
* @description Reactive `Array.prototype.some`. The source array and its items may be reactive.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
|
||||
* @param {(element: T, index: number, array: MaybeRefOrGetter<T>[]) => unknown} fn Predicate to test each element
|
||||
* @returns {UseArraySomeReturn} A computed boolean that is `true` if `fn` returns a truthy value for any element, otherwise `false`
|
||||
*
|
||||
* @example
|
||||
* const list = ref([1, 2, 3, 4]);
|
||||
* const hasEven = useArraySome(list, n => n % 2 === 0); // true
|
||||
*
|
||||
* @example
|
||||
* const items = [ref(1), ref(3), ref(5)];
|
||||
* const hasEven = useArraySome(items, n => n % 2 === 0); // false
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useArraySome<T>(
|
||||
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
|
||||
fn: (element: T, index: number, array: Array<MaybeRefOrGetter<T>>) => unknown,
|
||||
): UseArraySomeReturn {
|
||||
return computed(() => toValue(list).some((element, index, array) => fn(toValue(element), index, array)));
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { effectScope, ref } from 'vue';
|
||||
import { useArrayUnique } from '.';
|
||||
|
||||
describe(useArrayUnique, () => {
|
||||
it('de-duplicates primitive values using strict identity', () => {
|
||||
const list = ref([1, 2, 2, 3, 3, 3]);
|
||||
const result = useArrayUnique(list);
|
||||
expect(result.value).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('preserves first-seen insertion order', () => {
|
||||
const list = ref([3, 1, 3, 2, 1]);
|
||||
const result = useArrayUnique(list);
|
||||
expect(result.value).toEqual([3, 1, 2]);
|
||||
});
|
||||
|
||||
it('distinguishes values of different types with === semantics', () => {
|
||||
const list = ref<Array<number | string>>([1, '1', 1, '1']);
|
||||
const result = useArrayUnique(list);
|
||||
expect(result.value).toEqual([1, '1']);
|
||||
});
|
||||
|
||||
it('treats NaN occurrences as a single unique value', () => {
|
||||
const list = ref([Number.NaN, Number.NaN, 1]);
|
||||
const result = useArrayUnique(list);
|
||||
expect(result.value).toEqual([Number.NaN, 1]);
|
||||
});
|
||||
|
||||
it('returns an empty array for an empty source', () => {
|
||||
const list = ref<number[]>([]);
|
||||
const result = useArrayUnique(list);
|
||||
expect(result.value).toEqual([]);
|
||||
});
|
||||
|
||||
it('updates reactively when the source array changes', () => {
|
||||
const list = ref([1, 1, 2]);
|
||||
const result = useArrayUnique(list);
|
||||
expect(result.value).toEqual([1, 2]);
|
||||
|
||||
list.value = [3, 3, 3, 4];
|
||||
expect(result.value).toEqual([3, 4]);
|
||||
});
|
||||
|
||||
it('accepts a getter as the source', () => {
|
||||
const source = ref([1, 2, 2]);
|
||||
const result = useArrayUnique(() => source.value);
|
||||
expect(result.value).toEqual([1, 2]);
|
||||
|
||||
source.value = [5, 5, 6];
|
||||
expect(result.value).toEqual([5, 6]);
|
||||
});
|
||||
|
||||
it('unwraps reactive items', () => {
|
||||
const list = [ref(1), ref(1), ref(2)];
|
||||
const result = useArrayUnique(list);
|
||||
expect(result.value).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('reacts to changes in reactive items', () => {
|
||||
const a = ref(1);
|
||||
const b = ref(2);
|
||||
const result = useArrayUnique([a, b]);
|
||||
expect(result.value).toEqual([1, 2]);
|
||||
|
||||
b.value = 1;
|
||||
expect(result.value).toEqual([1]);
|
||||
});
|
||||
|
||||
it('de-duplicates by a key of T', () => {
|
||||
const list = ref([{ id: 1 }, { id: 2 }, { id: 1 }]);
|
||||
const result = useArrayUnique(list, 'id');
|
||||
expect(result.value).toEqual([{ id: 1 }, { id: 2 }]);
|
||||
});
|
||||
|
||||
it('keeps the first occurrence when de-duplicating by key', () => {
|
||||
const list = ref([
|
||||
{ id: 1, label: 'a' },
|
||||
{ id: 1, label: 'b' },
|
||||
{ id: 2, label: 'c' },
|
||||
]);
|
||||
const result = useArrayUnique(list, 'id');
|
||||
expect(result.value).toEqual([
|
||||
{ id: 1, label: 'a' },
|
||||
{ id: 2, label: 'c' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('de-duplicates by a key extractor function', () => {
|
||||
const list = ref([
|
||||
{ name: 'Ann' },
|
||||
{ name: 'Bob' },
|
||||
{ name: 'Ann' },
|
||||
]);
|
||||
const result = useArrayUnique(list, item => item.name);
|
||||
expect(result.value).toEqual([{ name: 'Ann' }, { name: 'Bob' }]);
|
||||
});
|
||||
|
||||
it('de-duplicates with a custom comparator function', () => {
|
||||
const list = ref([1.1, 1.4, 2.2, 2.9, 3.0]);
|
||||
const result = useArrayUnique(list, (a: number, b: number) => Math.floor(a) === Math.floor(b));
|
||||
expect(result.value).toEqual([1.1, 2.2, 3.0]);
|
||||
});
|
||||
|
||||
it('passes the resolved array to the comparator', () => {
|
||||
const list = ref([1, 2, 2]);
|
||||
const seen: number[] = [];
|
||||
const result = useArrayUnique(list, (a: number, b: number, array: number[]) => {
|
||||
seen.push(array.length);
|
||||
return a === b;
|
||||
});
|
||||
expect(result.value).toEqual([1, 2]);
|
||||
expect(seen.every(length => length === 3)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('reacts to changes when comparing by key', () => {
|
||||
const list = ref([{ id: 1 }, { id: 2 }, { id: 1 }]);
|
||||
const result = useArrayUnique(list, 'id');
|
||||
expect(result.value).toEqual([{ id: 1 }, { id: 2 }]);
|
||||
|
||||
list.value = [{ id: 3 }, { id: 3 }, { id: 4 }];
|
||||
expect(result.value).toEqual([{ id: 3 }, { id: 4 }]);
|
||||
});
|
||||
|
||||
it('reacts to changes when using a comparator function', () => {
|
||||
const list = ref([1.1, 1.9]);
|
||||
const result = useArrayUnique(list, (a: number, b: number) => Math.floor(a) === Math.floor(b));
|
||||
expect(result.value).toEqual([1.1]);
|
||||
|
||||
list.value = [1.1, 2.2, 2.9];
|
||||
expect(result.value).toEqual([1.1, 2.2]);
|
||||
});
|
||||
|
||||
it('works outside of a component instance (SSR-safe, no global access)', () => {
|
||||
// The composable must not touch window/document/navigator: running it inside
|
||||
// a bare effectScope (no component, no DOM globals needed) must succeed.
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useArrayUnique<number>> | undefined;
|
||||
|
||||
scope.run(() => {
|
||||
result = useArrayUnique(ref([1, 1, 2, 3, 3]));
|
||||
});
|
||||
|
||||
expect(result?.value).toEqual([1, 2, 3]);
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { computed, toValue } from 'vue';
|
||||
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
|
||||
import { isString, unique } from '@robonen/stdlib';
|
||||
|
||||
/**
|
||||
* Equality comparator deciding whether two array elements are duplicates.
|
||||
*/
|
||||
export type UseArrayUniqueComparatorFn<T>
|
||||
= (a: T, b: T, array: T[]) => boolean;
|
||||
|
||||
/**
|
||||
* Extracts the comparison key for an element. Two elements that produce the
|
||||
* same key (via `===`/`Set` identity) are considered duplicates.
|
||||
*/
|
||||
export type UseArrayUniqueKeyFn<T>
|
||||
= (element: T) => PropertyKey;
|
||||
|
||||
export type UseArrayUniqueReturn<T = unknown> = ComputedRef<T[]>;
|
||||
|
||||
/**
|
||||
* @name useArrayUnique
|
||||
* @category Array
|
||||
* @description Reactive de-duplicated array. By default uses `Set` identity (`===`); an optional key of `T`, key extractor (both O(n)), or full comparator (O(n²)) customizes equality. The source array and its items may be reactive. First-seen insertion order is preserved.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
|
||||
* @param {UseArrayUniqueComparatorFn<T> | UseArrayUniqueKeyFn<T> | keyof T} [comparator] A custom equality comparator, a key extractor, or a key of `T` to de-duplicate by
|
||||
* @returns {UseArrayUniqueReturn<T>} A computed array containing only the first occurrence of each unique element
|
||||
*
|
||||
* @example
|
||||
* const list = ref([1, 2, 2, 3, 3, 3]);
|
||||
* const uniq = useArrayUnique(list); // [1, 2, 3]
|
||||
*
|
||||
* @example
|
||||
* const list = ref([{ id: 1 }, { id: 2 }, { id: 1 }]);
|
||||
* const byId = useArrayUnique(list, 'id'); // [{ id: 1 }, { id: 2 }]
|
||||
*
|
||||
* @example
|
||||
* const list = ref([{ id: 1 }, { id: 2 }, { id: 1 }]);
|
||||
* const byKey = useArrayUnique(list, item => item.id); // [{ id: 1 }, { id: 2 }]
|
||||
*
|
||||
* @example
|
||||
* const list = ref([1.1, 1.4, 2.2]);
|
||||
* const byFloor = useArrayUnique(list, (a, b) => Math.floor(a) === Math.floor(b)); // [1.1, 2.2]
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useArrayUnique<T>(
|
||||
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
|
||||
): UseArrayUniqueReturn<T>;
|
||||
export function useArrayUnique<T>(
|
||||
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
|
||||
comparator: keyof T,
|
||||
): UseArrayUniqueReturn<T>;
|
||||
export function useArrayUnique<T>(
|
||||
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
|
||||
comparator: UseArrayUniqueKeyFn<T>,
|
||||
): UseArrayUniqueReturn<T>;
|
||||
export function useArrayUnique<T>(
|
||||
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
|
||||
comparator: UseArrayUniqueComparatorFn<T>,
|
||||
): UseArrayUniqueReturn<T>;
|
||||
export function useArrayUnique<T>(
|
||||
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
|
||||
comparator?: UseArrayUniqueComparatorFn<T> | UseArrayUniqueKeyFn<T> | keyof T,
|
||||
): UseArrayUniqueReturn<T> {
|
||||
// Resolve the comparison strategy once, not on every recompute.
|
||||
|
||||
// Key of T (string | number | symbol) -> O(n) first-seen-wins key de-dup.
|
||||
if (isString(comparator) || typeof comparator === 'symbol' || typeof comparator === 'number') {
|
||||
const key = comparator as keyof T;
|
||||
return computed<T[]>(() => uniqueByKey(resolve(list), element => element[key] as PropertyKey));
|
||||
}
|
||||
|
||||
if (typeof comparator === 'function') {
|
||||
// A unary key extractor stays O(n); a binary comparator falls back to O(n²)
|
||||
// pairwise comparison (unavoidable for arbitrary equality). Branch on arity.
|
||||
if (comparator.length <= 1) {
|
||||
const extractor = comparator as UseArrayUniqueKeyFn<T>;
|
||||
return computed<T[]>(() => uniqueByKey(resolve(list), extractor));
|
||||
}
|
||||
|
||||
const compare = comparator as UseArrayUniqueComparatorFn<T>;
|
||||
return computed<T[]>(() => {
|
||||
const array = resolve(list);
|
||||
const result: T[] = [];
|
||||
|
||||
for (const value of array) {
|
||||
if (!result.some(kept => compare(value, kept, array)))
|
||||
result.push(value);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
// Default: identity (`===`) de-dup via stdlib unique's Set fast path.
|
||||
return computed<T[]>(() => unique(resolve(list)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the (possibly reactive) list and each (possibly reactive) item.
|
||||
*/
|
||||
function resolve<T>(list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>): T[] {
|
||||
return toValue(list).map(element => toValue(element));
|
||||
}
|
||||
|
||||
/**
|
||||
* O(n) de-duplication that keeps the FIRST element seen per extracted key
|
||||
* (matching VueUse's first-occurrence semantics). stdlib `unique` is
|
||||
* last-write-wins per key, so we track seen keys in a Set here instead.
|
||||
*/
|
||||
function uniqueByKey<T>(array: T[], extractor: UseArrayUniqueKeyFn<T>): T[] {
|
||||
const seen = new Set<PropertyKey>();
|
||||
const result: T[] = [];
|
||||
|
||||
for (const element of array) {
|
||||
const key = extractor(element);
|
||||
|
||||
if (seen.has(key))
|
||||
continue;
|
||||
|
||||
seen.add(key);
|
||||
result.push(element);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isReactive, nextTick, reactive, ref } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import { useSorted } from '.';
|
||||
|
||||
describe(useSorted, () => {
|
||||
it('returns a sorted copy with the default numeric compare', () => {
|
||||
const source = ref([3, 1, 2]);
|
||||
const sorted = useSorted(source);
|
||||
expect(sorted.value).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('does not mutate the source by default', () => {
|
||||
const original = [3, 1, 2];
|
||||
const source = ref(original);
|
||||
const sorted = useSorted(source);
|
||||
expect(sorted.value).toEqual([1, 2, 3]);
|
||||
expect(source.value).toEqual([3, 1, 2]);
|
||||
expect(sorted.value).not.toBe(source.value);
|
||||
});
|
||||
|
||||
it('reacts to source changes', () => {
|
||||
const source = ref([3, 1, 2]);
|
||||
const sorted = useSorted(source);
|
||||
expect(sorted.value).toEqual([1, 2, 3]);
|
||||
source.value = [9, 5, 7, 1];
|
||||
expect(sorted.value).toEqual([1, 5, 7, 9]);
|
||||
});
|
||||
|
||||
it('supports a custom compare function as the second argument', () => {
|
||||
const source = ref([{ age: 30 }, { age: 18 }, { age: 25 }]);
|
||||
const sorted = useSorted(source, (a, b) => a.age - b.age);
|
||||
expect(sorted.value.map(u => u.age)).toEqual([18, 25, 30]);
|
||||
});
|
||||
|
||||
it('supports descending order via compare function', () => {
|
||||
const source = ref([1, 2, 3]);
|
||||
const sorted = useSorted(source, (a, b) => b - a);
|
||||
expect(sorted.value).toEqual([3, 2, 1]);
|
||||
});
|
||||
|
||||
it('accepts an options object as the second argument', () => {
|
||||
const source = ref([3, 1, 2]);
|
||||
const sorted = useSorted(source, { compareFn: (a, b) => b - a });
|
||||
expect(sorted.value).toEqual([3, 2, 1]);
|
||||
});
|
||||
|
||||
it('accepts a compare function plus an options object', () => {
|
||||
const calls: number[] = [];
|
||||
const sortFn = <T>(arr: T[], compareFn: (a: T, b: T) => number): T[] => {
|
||||
calls.push(arr.length);
|
||||
return [...arr].sort(compareFn);
|
||||
};
|
||||
const source = ref([3, 1, 2]);
|
||||
const sorted = useSorted(source, (a, b) => a - b, { sortFn });
|
||||
expect(sorted.value).toEqual([1, 2, 3]);
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('is stable: equal elements keep their original relative order', () => {
|
||||
const source = ref([
|
||||
{ k: 1, id: 'a' },
|
||||
{ k: 1, id: 'b' },
|
||||
{ k: 0, id: 'c' },
|
||||
{ k: 1, id: 'd' },
|
||||
]);
|
||||
const sorted = useSorted(source, (a, b) => a.k - b.k);
|
||||
expect(sorted.value.map(x => x.id)).toEqual(['c', 'a', 'b', 'd']);
|
||||
});
|
||||
|
||||
it('works with getter sources', () => {
|
||||
const base = ref([5, 3, 4]);
|
||||
const sorted = useSorted(() => base.value);
|
||||
expect(sorted.value).toEqual([3, 4, 5]);
|
||||
base.value = [2, 1];
|
||||
expect(sorted.value).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('works with a plain (non-reactive) array source', () => {
|
||||
const sorted = useSorted([3, 1, 2]);
|
||||
expect(sorted.value).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('handles empty and single-element arrays', () => {
|
||||
expect(useSorted(ref<number[]>([])).value).toEqual([]);
|
||||
expect(useSorted(ref([42])).value).toEqual([42]);
|
||||
});
|
||||
|
||||
describe('dirty mode', () => {
|
||||
it('sorts the source ref in place', async () => {
|
||||
const source = ref([3, 1, 2]);
|
||||
const result = useSorted(source, { dirty: true });
|
||||
await nextTick();
|
||||
expect(source.value).toEqual([1, 2, 3]);
|
||||
expect(result).toBe(source);
|
||||
});
|
||||
|
||||
it('re-sorts when the source changes', async () => {
|
||||
const source = ref([3, 1, 2]);
|
||||
useSorted(source, { dirty: true });
|
||||
await nextTick();
|
||||
expect(source.value).toEqual([1, 2, 3]);
|
||||
source.value = [9, 4, 6];
|
||||
await nextTick();
|
||||
expect(source.value).toEqual([4, 6, 9]);
|
||||
});
|
||||
|
||||
it('honors a custom compare function in dirty mode', async () => {
|
||||
const source = ref([1, 2, 3]);
|
||||
useSorted(source, (a, b) => b - a, { dirty: true });
|
||||
await nextTick();
|
||||
expect(source.value).toEqual([3, 2, 1]);
|
||||
});
|
||||
|
||||
it('mutates a reactive array source in place via a getter', async () => {
|
||||
const source = reactive([3, 1, 2]);
|
||||
useSorted(() => source, { dirty: true });
|
||||
await nextTick();
|
||||
expect(isReactive(source)).toBeTruthy();
|
||||
expect([...source]).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('writable result', () => {
|
||||
it('writes back to the source ref when assigned (non-dirty)', () => {
|
||||
const source = ref([3, 1, 2]);
|
||||
const sorted = useSorted(source);
|
||||
sorted.value = [10, 20];
|
||||
expect(source.value).toEqual([10, 20]);
|
||||
});
|
||||
|
||||
it('silently ignores writes when the source is a getter', () => {
|
||||
const base = ref([3, 1, 2]);
|
||||
const sorted = useSorted(() => base.value) as unknown as Ref<number[]>;
|
||||
expect(() => {
|
||||
sorted.value = [10, 20];
|
||||
}).not.toThrow();
|
||||
// getter source is unchanged
|
||||
expect(base.value).toEqual([3, 1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSR safety', () => {
|
||||
it('does not touch any DOM global and works without a document', () => {
|
||||
// useSorted is pure reactive computation; it must run identically in SSR.
|
||||
const sorted = useSorted(ref([3, 1, 2]));
|
||||
expect(sorted.value).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { computed, isRef, toValue, watchEffect } from 'vue';
|
||||
import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue';
|
||||
import { isFunction } from '@robonen/stdlib';
|
||||
|
||||
export type UseSortedCompareFn<T = any>
|
||||
= (a: T, b: T) => number;
|
||||
|
||||
export type UseSortedFn<T = any>
|
||||
= (arr: T[], compareFn: UseSortedCompareFn<T>) => T[];
|
||||
|
||||
export interface UseSortedOptions<T = any> {
|
||||
/**
|
||||
* The sort algorithm to apply. Receives a copy of the array (or the source
|
||||
* itself in `dirty` mode) and the resolved compare function.
|
||||
*
|
||||
* Defaults to a guaranteed-stable merge sort, so equal elements always keep
|
||||
* their original relative order regardless of the JS engine.
|
||||
*/
|
||||
sortFn?: UseSortedFn<T>;
|
||||
/**
|
||||
* The compare function used to order two elements, matching the signature of
|
||||
* `Array.prototype.sort`.
|
||||
*
|
||||
* @default (a, b) => a - b
|
||||
*/
|
||||
compareFn?: UseSortedCompareFn<T>;
|
||||
/**
|
||||
* Sort the source array in place instead of returning a sorted copy.
|
||||
*
|
||||
* When `true`, the returned ref is the source itself and its values are
|
||||
* re-sorted whenever the source changes.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
dirty?: boolean;
|
||||
}
|
||||
|
||||
const defaultCompare: UseSortedCompareFn<number> = (a, b) => a - b;
|
||||
|
||||
/**
|
||||
* Guaranteed-stable merge sort. Equal elements keep their original order on
|
||||
* every engine, unlike the historically engine-dependent `Array.prototype.sort`.
|
||||
*/
|
||||
function stableSort<T>(array: T[], compareFn: UseSortedCompareFn<T>): T[] {
|
||||
const length = array.length;
|
||||
if (length < 2)
|
||||
return array;
|
||||
|
||||
const middle = length >> 1;
|
||||
const left = stableSort(array.slice(0, middle), compareFn);
|
||||
const right = stableSort(array.slice(middle), compareFn);
|
||||
|
||||
const result: T[] = Array.from({ length });
|
||||
let i = 0;
|
||||
let l = 0;
|
||||
let r = 0;
|
||||
|
||||
while (l < left.length && r < right.length) {
|
||||
// Bounds are guaranteed by the loop condition; `!` drops the index-access undefined.
|
||||
// `<= 0` keeps left (earlier) element first -> stability.
|
||||
if (compareFn(left[l]!, right[r]!) <= 0)
|
||||
result[i++] = left[l++]!;
|
||||
else
|
||||
result[i++] = right[r++]!;
|
||||
}
|
||||
|
||||
while (l < left.length)
|
||||
result[i++] = left[l++]!;
|
||||
while (r < right.length)
|
||||
result[i++] = right[r++]!;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const defaultSortFn: UseSortedFn = <T>(source: T[], compareFn: UseSortedCompareFn<T>): T[] => stableSort(source, compareFn);
|
||||
|
||||
/**
|
||||
* @name useSorted
|
||||
* @category Array
|
||||
* @description Reactive, stable sorted copy of an array. Mirrors `Array.prototype.sort` but never mutates the source by default and guarantees stable ordering.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<T[]>} source The source array (ref, getter, or plain array)
|
||||
* @param {UseSortedCompareFn<T> | UseSortedOptions<T>} [compareFn] A compare function, or an options object
|
||||
* @param {Omit<UseSortedOptions<T>, 'compareFn'>} [options] Extra options when the second argument is a compare function
|
||||
* @returns {ComputedRef<T[]> | Ref<T[]>} A computed sorted copy (default), or the source ref when `dirty` is `true`
|
||||
*
|
||||
* @example
|
||||
* const list = ref([3, 1, 2]);
|
||||
* const sorted = useSorted(list); // [1, 2, 3]
|
||||
*
|
||||
* @example
|
||||
* // custom compare function
|
||||
* const users = ref([{ age: 30 }, { age: 18 }]);
|
||||
* const byAge = useSorted(users, (a, b) => a.age - b.age);
|
||||
*
|
||||
* @example
|
||||
* // sort the source in place
|
||||
* const list = ref([3, 1, 2]);
|
||||
* useSorted(list, { dirty: true });
|
||||
* // list.value is now [1, 2, 3]
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useSorted<T = any>(source: Ref<T[]>, compareFn?: UseSortedCompareFn<T>): Ref<T[]>;
|
||||
export function useSorted<T = any>(source: MaybeRefOrGetter<T[]>, compareFn?: UseSortedCompareFn<T>): ComputedRef<T[]>;
|
||||
export function useSorted<T = any>(source: Ref<T[]>, options?: UseSortedOptions<T>): Ref<T[]>;
|
||||
export function useSorted<T = any>(source: MaybeRefOrGetter<T[]>, options?: UseSortedOptions<T>): ComputedRef<T[]>;
|
||||
export function useSorted<T = any>(source: Ref<T[]>, compareFn?: UseSortedCompareFn<T>, options?: Omit<UseSortedOptions<T>, 'compareFn'>): Ref<T[]>;
|
||||
export function useSorted<T = any>(source: MaybeRefOrGetter<T[]>, compareFn?: UseSortedCompareFn<T>, options?: Omit<UseSortedOptions<T>, 'compareFn'>): ComputedRef<T[]>;
|
||||
export function useSorted<T = any>(
|
||||
source: MaybeRefOrGetter<T[]>,
|
||||
compareFnOrOptions?: UseSortedCompareFn<T> | UseSortedOptions<T>,
|
||||
maybeOptions?: Omit<UseSortedOptions<T>, 'compareFn'>,
|
||||
): ComputedRef<T[]> | Ref<T[]> {
|
||||
let compareFn: UseSortedCompareFn<T> = defaultCompare as UseSortedCompareFn<T>;
|
||||
let options: UseSortedOptions<T> = {};
|
||||
|
||||
if (isFunction(compareFnOrOptions)) {
|
||||
compareFn = compareFnOrOptions;
|
||||
options = maybeOptions ?? {};
|
||||
}
|
||||
else if (compareFnOrOptions) {
|
||||
options = compareFnOrOptions;
|
||||
compareFn = options.compareFn ?? (defaultCompare as UseSortedCompareFn<T>);
|
||||
}
|
||||
|
||||
const {
|
||||
dirty = false,
|
||||
sortFn = defaultSortFn,
|
||||
} = options;
|
||||
|
||||
if (!dirty) {
|
||||
return computed<T[]>({
|
||||
get: () => sortFn([...toValue(source)], compareFn),
|
||||
set: (value) => {
|
||||
if (isRef(source))
|
||||
(source as Ref<T[]>).value = value;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
const result = sortFn(toValue(source), compareFn);
|
||||
if (isRef(source))
|
||||
(source as Ref<T[]>).value = result;
|
||||
else
|
||||
(toValue(source)).splice(0, toValue(source).length, ...result);
|
||||
});
|
||||
|
||||
return source as Ref<T[]>;
|
||||
}
|
||||
+1
-1
@@ -13,7 +13,7 @@ export interface BroadcastedRefOptions {
|
||||
|
||||
/**
|
||||
* @name broadcastedRef
|
||||
* @category Reactivity
|
||||
* @category Browser
|
||||
* @description Creates a custom ref that syncs its value across browser tabs via the BroadcastChannel API
|
||||
*
|
||||
* @param {string} key The channel key to use for broadcasting
|
||||
@@ -1,58 +1,36 @@
|
||||
export * from './onKeyStroke';
|
||||
export * from './useActiveElement';
|
||||
export * from './useBodyScrollLock';
|
||||
export * from './useClickOutside';
|
||||
export * from './broadcastedRef';
|
||||
export * from './useBreakpoints';
|
||||
export * from './useClipboard';
|
||||
export * from './useClipboardItems';
|
||||
export * from './useCloseWatcher';
|
||||
export * from './useColorMode';
|
||||
export * from './useDevicePixelRatio';
|
||||
export * from './useDocumentReadyState';
|
||||
export * from './useDocumentVisibility';
|
||||
export * from './useDropZone';
|
||||
export * from './useElementBounding';
|
||||
export * from './useElementHover';
|
||||
export * from './useElementSize';
|
||||
export * from './useElementVisibility';
|
||||
export * from './useEscapeKey';
|
||||
export * from './useCssVar';
|
||||
export * from './useDark';
|
||||
export * from './useDocumentPiP';
|
||||
export * from './useEventListener';
|
||||
export * from './useEyeDropper';
|
||||
export * from './useFavicon';
|
||||
export * from './useFileDialog';
|
||||
export * from './useFocus';
|
||||
export * from './useFocusGuard';
|
||||
export * from './useFocusWithin';
|
||||
export * from './useFps';
|
||||
export * from './useFileSystemAccess';
|
||||
export * from './useFullscreen';
|
||||
export * from './useGeolocation';
|
||||
export * from './useIdle';
|
||||
export * from './useIntersectionObserver';
|
||||
export * from './useIntervalFn';
|
||||
export * from './useKeyModifier';
|
||||
export * from './useMagicKeys';
|
||||
export * from './useImage';
|
||||
export * from './useLocalFonts';
|
||||
export * from './useMediaQuery';
|
||||
export * from './useMouse';
|
||||
export * from './useMousePressed';
|
||||
export * from './useMutationObserver';
|
||||
export * from './useNetwork';
|
||||
export * from './useObjectUrl';
|
||||
export * from './useOnline';
|
||||
export * from './usePageLeave';
|
||||
export * from './usePermission';
|
||||
export * from './usePointer';
|
||||
export * from './usePreferredColorScheme';
|
||||
export * from './usePreferredContrast';
|
||||
export * from './usePreferredDark';
|
||||
export * from './useRafFn';
|
||||
export * from './useResizeObserver';
|
||||
export * from './useScreenOrientation';
|
||||
export * from './useScroll';
|
||||
export * from './useScrollLock';
|
||||
export * from './usePreferredLanguages';
|
||||
export * from './usePreferredReducedMotion';
|
||||
export * from './usePreferredReducedTransparency';
|
||||
export * from './useScriptTag';
|
||||
export * from './useShare';
|
||||
export * from './useSupported';
|
||||
export * from './useSwipe';
|
||||
export * from './useStyleTag';
|
||||
export * from './useTabLeader';
|
||||
export * from './useTextSelection';
|
||||
export * from './useTextareaAutosize';
|
||||
export * from './useTitle';
|
||||
export * from './useUrlSearchParams';
|
||||
export * from './useVibrate';
|
||||
export * from './useWindowFocus';
|
||||
export * from './useWindowScroll';
|
||||
export * from './useWindowSize';
|
||||
export * from './useWakeLock';
|
||||
export * from './useWebNotification';
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick, ref } from 'vue';
|
||||
import {
|
||||
breakpointsAntDesign,
|
||||
breakpointsBootstrapV5,
|
||||
breakpointsTailwind,
|
||||
breakpointsVuetifyV3,
|
||||
useBreakpoints,
|
||||
} from '.';
|
||||
|
||||
type Listener = (event: { matches: boolean }) => void;
|
||||
|
||||
interface StubMql {
|
||||
readonly matches: boolean;
|
||||
media: string;
|
||||
addEventListener: (type: string, cb: Listener) => void;
|
||||
removeEventListener: (type: string, cb: Listener) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A `matchMedia` stub backed by a mutable viewport width. It parses
|
||||
* `(min-width: Npx)` / `(max-width: Npx)` (optionally joined by `and`) so each
|
||||
* query evaluates against the current `width`. Calling `setWidth` re-dispatches
|
||||
* `change` to every live MediaQueryList, mimicking a real viewport resize.
|
||||
*/
|
||||
function stubViewport(initialWidth: number) {
|
||||
let width = initialWidth;
|
||||
const lists = new Set<{ media: string; matches: boolean; listeners: Set<Listener> }>();
|
||||
|
||||
function toPx(value: string): number {
|
||||
const n = Number.parseFloat(value);
|
||||
return /(?:em|rem)$/i.test(value) ? n * 16 : n;
|
||||
}
|
||||
|
||||
function evaluate(media: string): boolean {
|
||||
return media.split(' and ').every((part) => {
|
||||
const min = part.match(/min-width:\s*(-?\d+(?:\.\d+)?(?:px|r?em)?)/);
|
||||
const max = part.match(/max-width:\s*(-?\d+(?:\.\d+)?(?:px|r?em)?)/);
|
||||
|
||||
if (min) return width >= toPx(min[1]!);
|
||||
if (max) return width <= toPx(max[1]!);
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
const matchMedia = vi.fn((media: string): StubMql => {
|
||||
const entry = { media, matches: evaluate(media), listeners: new Set<Listener>() };
|
||||
lists.add(entry);
|
||||
|
||||
return {
|
||||
get matches() {
|
||||
return entry.matches;
|
||||
},
|
||||
media,
|
||||
addEventListener: (_: string, cb: Listener) => entry.listeners.add(cb),
|
||||
removeEventListener: (_: string, cb: Listener) => entry.listeners.delete(cb),
|
||||
};
|
||||
});
|
||||
|
||||
vi.stubGlobal('matchMedia', matchMedia);
|
||||
|
||||
return {
|
||||
matchMedia,
|
||||
setWidth(next: number) {
|
||||
width = next;
|
||||
|
||||
for (const entry of lists) {
|
||||
const matches = evaluate(entry.media);
|
||||
|
||||
if (matches !== entry.matches) {
|
||||
entry.matches = matches;
|
||||
|
||||
for (const cb of entry.listeners) cb({ matches });
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe(useBreakpoints, () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('matchMedia', undefined);
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('exposes a reactive shortcut ref per breakpoint (min-width strategy)', async () => {
|
||||
stubViewport(800);
|
||||
const scope = effectScope();
|
||||
let bp: ReturnType<typeof useBreakpoints<'sm' | 'md' | 'lg'>>;
|
||||
|
||||
scope.run(() => {
|
||||
bp = useBreakpoints({ sm: 640, md: 768, lg: 1024 });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
// 800px: >= sm (640), >= md (768), < lg (1024)
|
||||
expect(bp!.sm.value).toBeTruthy();
|
||||
expect(bp!.md.value).toBeTruthy();
|
||||
expect(bp!.lg.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('uses max-width semantics for shortcuts under the max-width strategy', async () => {
|
||||
stubViewport(800);
|
||||
const scope = effectScope();
|
||||
let bp: ReturnType<typeof useBreakpoints<'sm' | 'md' | 'lg'>>;
|
||||
|
||||
scope.run(() => {
|
||||
bp = useBreakpoints({ sm: 640, md: 768, lg: 1024 }, { strategy: 'max-width' });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
// 800px: <= lg (1024) only; not <= sm/md
|
||||
expect(bp!.sm.value).toBeFalsy();
|
||||
expect(bp!.md.value).toBeFalsy();
|
||||
expect(bp!.lg.value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reacts to viewport changes', async () => {
|
||||
const vp = stubViewport(500);
|
||||
const scope = effectScope();
|
||||
let bp: ReturnType<typeof useBreakpoints<'sm' | 'lg'>>;
|
||||
|
||||
scope.run(() => {
|
||||
bp = useBreakpoints({ sm: 640, lg: 1024 });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(bp!.greaterOrEqual('sm').value).toBeFalsy();
|
||||
|
||||
vp.setWidth(700);
|
||||
await nextTick();
|
||||
expect(bp!.greaterOrEqual('sm').value).toBeTruthy();
|
||||
expect(bp!.greaterOrEqual('lg').value).toBeFalsy();
|
||||
|
||||
vp.setWidth(1100);
|
||||
await nextTick();
|
||||
expect(bp!.greaterOrEqual('lg').value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('greater/smaller apply the strict (exclusive) delta', async () => {
|
||||
// Exactly at the md breakpoint (768).
|
||||
const vp = stubViewport(768);
|
||||
const scope = effectScope();
|
||||
let bp: ReturnType<typeof useBreakpoints<'md'>>;
|
||||
|
||||
scope.run(() => {
|
||||
bp = useBreakpoints({ md: 768 });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
// At exactly 768: greaterOrEqual true, greater false, smallerOrEqual true, smaller false.
|
||||
expect(bp!.greaterOrEqual('md').value).toBeTruthy();
|
||||
expect(bp!.greater('md').value).toBeFalsy();
|
||||
expect(bp!.smallerOrEqual('md').value).toBeTruthy();
|
||||
expect(bp!.smaller('md').value).toBeFalsy();
|
||||
|
||||
vp.setWidth(900);
|
||||
await nextTick();
|
||||
expect(bp!.greater('md').value).toBeTruthy();
|
||||
expect(bp!.smaller('md').value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('between is half-open [a, b)', async () => {
|
||||
const vp = stubViewport(768);
|
||||
const scope = effectScope();
|
||||
let bp: ReturnType<typeof useBreakpoints<'sm' | 'md' | 'lg'>>;
|
||||
|
||||
scope.run(() => {
|
||||
bp = useBreakpoints({ sm: 640, md: 768, lg: 1024 });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
// 768 is in [sm, lg) and in [md, lg); the upper bound (md) is exclusive in [sm, md).
|
||||
expect(bp!.between('sm', 'lg').value).toBeTruthy();
|
||||
expect(bp!.between('sm', 'md').value).toBeFalsy();
|
||||
expect(bp!.between('md', 'lg').value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('current() returns active breakpoints ordered small to large; active() picks per strategy', async () => {
|
||||
stubViewport(800);
|
||||
const scope = effectScope();
|
||||
let bp: ReturnType<typeof useBreakpoints<'sm' | 'md' | 'lg'>>;
|
||||
|
||||
scope.run(() => {
|
||||
bp = useBreakpoints({ lg: 1024, sm: 640, md: 768 });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
// 800px: sm and md active, sorted ascending.
|
||||
expect(bp!.current().value).toEqual(['sm', 'md']);
|
||||
// min-width strategy → largest active breakpoint.
|
||||
expect(bp!.active().value).toBe('md');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('active() picks the smallest active breakpoint under max-width strategy', async () => {
|
||||
stubViewport(800);
|
||||
const scope = effectScope();
|
||||
let bp: ReturnType<typeof useBreakpoints<'sm' | 'md' | 'lg'>>;
|
||||
|
||||
scope.run(() => {
|
||||
bp = useBreakpoints({ sm: 640, md: 768, lg: 1024 }, { strategy: 'max-width' });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(bp!.active().value).toBe('lg');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('synchronous is* helpers read the current match', async () => {
|
||||
stubViewport(800);
|
||||
const scope = effectScope();
|
||||
let bp: ReturnType<typeof useBreakpoints<'sm' | 'md' | 'lg'>>;
|
||||
|
||||
scope.run(() => {
|
||||
bp = useBreakpoints({ sm: 640, md: 768, lg: 1024 });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(bp!.isGreaterOrEqual('md')).toBeTruthy();
|
||||
expect(bp!.isGreater('md')).toBeTruthy();
|
||||
expect(bp!.isGreaterOrEqual('lg')).toBeFalsy();
|
||||
expect(bp!.isSmallerOrEqual('lg')).toBeTruthy();
|
||||
expect(bp!.isSmaller('sm')).toBeFalsy();
|
||||
expect(bp!.isInBetween('sm', 'lg')).toBeTruthy();
|
||||
expect(bp!.isInBetween('sm', 'md')).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('supports reactive breakpoint values', async () => {
|
||||
const vp = stubViewport(700);
|
||||
const threshold = ref(640);
|
||||
const scope = effectScope();
|
||||
let bp: ReturnType<typeof useBreakpoints<'point'>>;
|
||||
|
||||
scope.run(() => {
|
||||
bp = useBreakpoints({ point: threshold });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(bp!.greaterOrEqual('point').value).toBeTruthy();
|
||||
|
||||
threshold.value = 900;
|
||||
await nextTick();
|
||||
expect(bp!.greaterOrEqual('point').value).toBeFalsy();
|
||||
|
||||
vp.setWidth(1000);
|
||||
await nextTick();
|
||||
expect(bp!.greaterOrEqual('point').value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('handles string unit breakpoint values', async () => {
|
||||
stubViewport(800); // 800px, 48em = 768px
|
||||
const scope = effectScope();
|
||||
let bp: ReturnType<typeof useBreakpoints<'md'>>;
|
||||
|
||||
scope.run(() => {
|
||||
bp = useBreakpoints({ md: '48em' });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(bp!.greaterOrEqual('md').value).toBeTruthy();
|
||||
expect(bp!.isGreaterOrEqual('md')).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
describe('SSR / unsupported path', () => {
|
||||
it('resolves width queries from ssrWidth when matchMedia is unavailable', () => {
|
||||
// matchMedia stays undefined (beforeEach), window has no matchMedia path used by match().
|
||||
const scope = effectScope();
|
||||
let bp: ReturnType<typeof useBreakpoints<'sm' | 'lg'>>;
|
||||
|
||||
scope.run(() => {
|
||||
bp = useBreakpoints({ sm: 640, lg: 1024 }, { ssrWidth: 800 });
|
||||
});
|
||||
|
||||
// Synchronous helpers resolve against ssrWidth.
|
||||
expect(bp!.isGreaterOrEqual('sm')).toBeTruthy();
|
||||
expect(bp!.isGreaterOrEqual('lg')).toBeFalsy();
|
||||
expect(bp!.isSmallerOrEqual('lg')).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('returns false for snapshot helpers with no window and no ssrWidth', () => {
|
||||
const scope = effectScope();
|
||||
let bp: ReturnType<typeof useBreakpoints<'sm'>>;
|
||||
|
||||
scope.run(() => {
|
||||
bp = useBreakpoints({ sm: 640 }, { window: undefined });
|
||||
});
|
||||
|
||||
expect(bp!.isGreaterOrEqual('sm')).toBeFalsy();
|
||||
expect(bp!.isSmallerOrEqual('sm')).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not throw when constructed without a window (SSR)', () => {
|
||||
const scope = effectScope();
|
||||
|
||||
expect(() => {
|
||||
scope.run(() => {
|
||||
const bp = useBreakpoints(breakpointsTailwind, { window: undefined });
|
||||
// Accessing reactive refs should be safe and default to false.
|
||||
expect(bp.lg.value).toBeFalsy();
|
||||
});
|
||||
}).not.toThrow();
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe('presets', () => {
|
||||
it('exports the expected preset values', () => {
|
||||
expect(breakpointsTailwind).toMatchObject({ sm: 640, md: 768, lg: 1024, xl: 1280, '2xl': 1536 });
|
||||
expect(breakpointsBootstrapV5).toMatchObject({ xs: 0, sm: 576, md: 768, lg: 992, xl: 1200, xxl: 1400 });
|
||||
expect(breakpointsAntDesign).toMatchObject({ xs: 480, sm: 576, md: 768, lg: 992, xl: 1200, xxl: 1600 });
|
||||
expect(breakpointsVuetifyV3).toMatchObject({ xs: 0, sm: 600, md: 960, lg: 1280, xl: 1920, xxl: 2560 });
|
||||
});
|
||||
|
||||
it('works with a preset', async () => {
|
||||
stubViewport(1300);
|
||||
const scope = effectScope();
|
||||
let bp: ReturnType<typeof useBreakpoints<keyof typeof breakpointsTailwind & string>>;
|
||||
|
||||
scope.run(() => {
|
||||
bp = useBreakpoints(breakpointsTailwind);
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(bp!.greaterOrEqual('xl').value).toBeTruthy();
|
||||
expect(bp!.greaterOrEqual('2xl').value).toBeFalsy();
|
||||
expect(bp!.active().value).toBe('xl');
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
import { computed, toValue } from 'vue';
|
||||
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
|
||||
import { isFunction, isNumber } from '@robonen/stdlib';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { useMediaQuery } from '@/composables/browser/useMediaQuery';
|
||||
import type { UseMediaQueryOptions } from '@/composables/browser/useMediaQuery';
|
||||
|
||||
/**
|
||||
* A breakpoints map: name → viewport width. Numbers are treated as pixels;
|
||||
* strings keep their unit (`"48em"`, `"30rem"`, `"1024px"`). Values may be
|
||||
* reactive (refs or getters).
|
||||
*/
|
||||
export type Breakpoints<K extends string = string>
|
||||
= Record<K, MaybeRefOrGetter<number | string>>;
|
||||
|
||||
/**
|
||||
* Which edge a generated shortcut property (e.g. `breakpoints.lg`) reacts to.
|
||||
*
|
||||
* - `'min-width'` (mobile-first) — `lg` is `true` when the viewport is at least
|
||||
* the `lg` width.
|
||||
* - `'max-width'` (desktop-first) — `lg` is `true` when the viewport is at most
|
||||
* the `lg` width.
|
||||
*/
|
||||
export type UseBreakpointsStrategy = 'min-width' | 'max-width';
|
||||
|
||||
export interface UseBreakpointsOptions extends ConfigurableWindow, Pick<UseMediaQueryOptions, 'ssrWidth'> {
|
||||
/**
|
||||
* The query strategy used by the generated shortcut properties.
|
||||
*
|
||||
* @default 'min-width'
|
||||
*/
|
||||
strategy?: UseBreakpointsStrategy;
|
||||
}
|
||||
|
||||
export type UseBreakpointsReturn<K extends string = string>
|
||||
= Record<K, ComputedRef<boolean>> & {
|
||||
/** Reactive: viewport width is greater than or equal to breakpoint `k` (`min-width`). */
|
||||
greaterOrEqual: (k: MaybeRefOrGetter<K>) => ComputedRef<boolean>;
|
||||
/** Reactive: viewport width is smaller than or equal to breakpoint `k` (`max-width`). */
|
||||
smallerOrEqual: (k: MaybeRefOrGetter<K>) => ComputedRef<boolean>;
|
||||
/** Reactive: viewport width is strictly greater than breakpoint `k`. */
|
||||
greater: (k: MaybeRefOrGetter<K>) => ComputedRef<boolean>;
|
||||
/** Reactive: viewport width is strictly smaller than breakpoint `k`. */
|
||||
smaller: (k: MaybeRefOrGetter<K>) => ComputedRef<boolean>;
|
||||
/** Reactive: viewport width is within `[a, b)`. */
|
||||
between: (a: MaybeRefOrGetter<K>, b: MaybeRefOrGetter<K>) => ComputedRef<boolean>;
|
||||
/** Snapshot: viewport width is strictly greater than breakpoint `k`. */
|
||||
isGreater: (k: MaybeRefOrGetter<K>) => boolean;
|
||||
/** Snapshot: viewport width is greater than or equal to breakpoint `k`. */
|
||||
isGreaterOrEqual: (k: MaybeRefOrGetter<K>) => boolean;
|
||||
/** Snapshot: viewport width is strictly smaller than breakpoint `k`. */
|
||||
isSmaller: (k: MaybeRefOrGetter<K>) => boolean;
|
||||
/** Snapshot: viewport width is smaller than or equal to breakpoint `k`. */
|
||||
isSmallerOrEqual: (k: MaybeRefOrGetter<K>) => boolean;
|
||||
/** Snapshot: viewport width is within `[a, b)`. */
|
||||
isInBetween: (a: MaybeRefOrGetter<K>, b: MaybeRefOrGetter<K>) => boolean;
|
||||
/** Reactive: all currently active breakpoints, ordered small → large. */
|
||||
current: () => ComputedRef<K[]>;
|
||||
/** Reactive: the single active breakpoint per `strategy` (largest for `min-width`, smallest for `max-width`), or `''` when none. */
|
||||
active: () => ComputedRef<K | ''>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a CSS length token (`"1024px"`, `"48em"`, `"30rem"`, `"50%"`) into a
|
||||
* pixel number. `em`/`rem` use the conventional 16px root size.
|
||||
*/
|
||||
function pxValue(value: string): number {
|
||||
const number = Number.parseFloat(value);
|
||||
|
||||
if (Number.isNaN(number))
|
||||
return Number.NaN;
|
||||
|
||||
if (/(?:em|rem)\s*$/i.test(value))
|
||||
return number * 16;
|
||||
|
||||
return number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add `delta` to the numeric portion of a CSS length, preserving its unit.
|
||||
* Used to build the strict (`> / <`) variants from inclusive media queries via
|
||||
* a small ±0.1 nudge.
|
||||
*/
|
||||
function increaseWithUnit(target: number | string, delta: number): number | string {
|
||||
if (isNumber(target))
|
||||
return target + delta;
|
||||
|
||||
const value = target.match(/^-?\d+(?:\.\d+)?/)?.[0] ?? '';
|
||||
const unit = target.slice(value.length);
|
||||
const result = Number.parseFloat(value) + delta;
|
||||
|
||||
if (Number.isNaN(result))
|
||||
return target;
|
||||
|
||||
return result + unit;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useBreakpoints
|
||||
* @category Browser
|
||||
* @description Reactive viewport breakpoints derived from a breakpoints map.
|
||||
* SSR-safe (resolves width queries from `ssrWidth` before `matchMedia` exists),
|
||||
* reactive to breakpoint values, and built on a single `useMediaQuery` per
|
||||
* comparison. Comes with presets: `breakpointsTailwind`, `breakpointsBootstrapV5`,
|
||||
* `breakpointsAntDesign`, `breakpointsVuetifyV3`.
|
||||
*
|
||||
* @param {Breakpoints<K>} breakpoints The breakpoints map (`name → width`)
|
||||
* @param {UseBreakpointsOptions} [options={}] Options (`strategy`, custom `window`, `ssrWidth`)
|
||||
* @returns {UseBreakpointsReturn<K>} Shortcut refs per breakpoint plus comparison helpers
|
||||
*
|
||||
* @example
|
||||
* const bp = useBreakpoints(breakpointsTailwind);
|
||||
* const isDesktop = bp.greaterOrEqual('lg');
|
||||
* const isMobile = bp.smaller('md');
|
||||
* bp.lg; // ComputedRef<boolean> — true when viewport >= 1024px
|
||||
*
|
||||
* @example
|
||||
* const bp = useBreakpoints({ mobile: 0, tablet: 640, desktop: 1024 });
|
||||
* const active = bp.active(); // ComputedRef<'mobile' | 'tablet' | 'desktop' | ''>
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useBreakpoints<K extends string>(
|
||||
breakpoints: Breakpoints<K>,
|
||||
options: UseBreakpointsOptions = {},
|
||||
): UseBreakpointsReturn<K> {
|
||||
const { window = defaultWindow, strategy = 'min-width', ssrWidth } = options;
|
||||
const mediaOptions: UseMediaQueryOptions = { window, ssrWidth };
|
||||
const ssrSupport = isNumber(ssrWidth);
|
||||
|
||||
function getValue(k: MaybeRefOrGetter<K>, delta?: number): string {
|
||||
let v = toValue(breakpoints[toValue(k)]);
|
||||
|
||||
if (delta !== undefined)
|
||||
v = increaseWithUnit(v, delta);
|
||||
|
||||
return isNumber(v) ? `${v}px` : v;
|
||||
}
|
||||
|
||||
// Synchronous (non-reactive) match for the `is*` snapshot helpers.
|
||||
function match(edge: 'min' | 'max', size: string): boolean {
|
||||
const supported = window && isFunction(window.matchMedia);
|
||||
|
||||
if (!supported)
|
||||
return ssrSupport
|
||||
? (edge === 'min' ? ssrWidth >= pxValue(size) : ssrWidth <= pxValue(size))
|
||||
: false;
|
||||
|
||||
return window.matchMedia(`(${edge}-width: ${size})`).matches;
|
||||
}
|
||||
|
||||
const greaterOrEqual = (k: MaybeRefOrGetter<K>): ComputedRef<boolean> =>
|
||||
useMediaQuery(() => `(min-width: ${getValue(k)})`, mediaOptions);
|
||||
|
||||
const smallerOrEqual = (k: MaybeRefOrGetter<K>): ComputedRef<boolean> =>
|
||||
useMediaQuery(() => `(max-width: ${getValue(k)})`, mediaOptions);
|
||||
|
||||
const greater = (k: MaybeRefOrGetter<K>): ComputedRef<boolean> =>
|
||||
useMediaQuery(() => `(min-width: ${getValue(k, 0.1)})`, mediaOptions);
|
||||
|
||||
const smaller = (k: MaybeRefOrGetter<K>): ComputedRef<boolean> =>
|
||||
useMediaQuery(() => `(max-width: ${getValue(k, -0.1)})`, mediaOptions);
|
||||
|
||||
const between = (a: MaybeRefOrGetter<K>, b: MaybeRefOrGetter<K>): ComputedRef<boolean> =>
|
||||
useMediaQuery(() => `(min-width: ${getValue(a)}) and (max-width: ${getValue(b, -0.1)})`, mediaOptions);
|
||||
|
||||
const keys = Object.keys(breakpoints) as K[];
|
||||
|
||||
// Generated shortcut properties (`bp.lg`). Lazily created getters so we only
|
||||
// spin up a `useMediaQuery` watcher for the breakpoints actually accessed.
|
||||
const shortcuts = keys.reduce((acc, k) => {
|
||||
Object.defineProperty(acc, k, {
|
||||
get: () => strategy === 'min-width' ? greaterOrEqual(k) : smallerOrEqual(k),
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
return acc;
|
||||
}, {} as Record<K, ComputedRef<boolean>>);
|
||||
|
||||
function current(): ComputedRef<K[]> {
|
||||
const points = keys
|
||||
.map(k => [k, shortcuts[k], pxValue(getValue(k))] as const)
|
||||
.sort((a, b) => a[2] - b[2]);
|
||||
|
||||
return computed(() => points.filter(([, matches]) => matches.value).map(([k]) => k));
|
||||
}
|
||||
|
||||
return Object.assign(shortcuts, {
|
||||
greaterOrEqual,
|
||||
smallerOrEqual,
|
||||
greater,
|
||||
smaller,
|
||||
between,
|
||||
isGreater: (k: MaybeRefOrGetter<K>): boolean => match('min', getValue(k, 0.1)),
|
||||
isGreaterOrEqual: (k: MaybeRefOrGetter<K>): boolean => match('min', getValue(k)),
|
||||
isSmaller: (k: MaybeRefOrGetter<K>): boolean => match('max', getValue(k, -0.1)),
|
||||
isSmallerOrEqual: (k: MaybeRefOrGetter<K>): boolean => match('max', getValue(k)),
|
||||
isInBetween: (a: MaybeRefOrGetter<K>, b: MaybeRefOrGetter<K>): boolean =>
|
||||
match('min', getValue(a)) && match('max', getValue(b, -0.1)),
|
||||
current,
|
||||
active(): ComputedRef<K | ''> {
|
||||
const bps = current();
|
||||
|
||||
return computed(() => bps.value.length === 0
|
||||
? ''
|
||||
: bps.value.at(strategy === 'min-width' ? -1 : 0)!);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tailwind CSS default breakpoints.
|
||||
*
|
||||
* @see https://tailwindcss.com/docs/responsive-design
|
||||
*/
|
||||
export const breakpointsTailwind = {
|
||||
sm: 640,
|
||||
md: 768,
|
||||
lg: 1024,
|
||||
xl: 1280,
|
||||
'2xl': 1536,
|
||||
};
|
||||
|
||||
/**
|
||||
* Bootstrap v5 default breakpoints.
|
||||
*
|
||||
* @see https://getbootstrap.com/docs/5.0/layout/breakpoints/
|
||||
*/
|
||||
export const breakpointsBootstrapV5 = {
|
||||
xs: 0,
|
||||
sm: 576,
|
||||
md: 768,
|
||||
lg: 992,
|
||||
xl: 1200,
|
||||
xxl: 1400,
|
||||
};
|
||||
|
||||
/**
|
||||
* Ant Design default breakpoints.
|
||||
*
|
||||
* @see https://ant.design/components/grid#col
|
||||
*/
|
||||
export const breakpointsAntDesign = {
|
||||
xs: 480,
|
||||
sm: 576,
|
||||
md: 768,
|
||||
lg: 992,
|
||||
xl: 1200,
|
||||
xxl: 1600,
|
||||
};
|
||||
|
||||
/**
|
||||
* Vuetify v3 default breakpoints.
|
||||
*
|
||||
* @see https://vuetifyjs.com/en/features/display-and-platform/
|
||||
*/
|
||||
export const breakpointsVuetifyV3 = {
|
||||
xs: 0,
|
||||
sm: 600,
|
||||
md: 960,
|
||||
lg: 1280,
|
||||
xl: 1920,
|
||||
xxl: 2560,
|
||||
};
|
||||
@@ -3,9 +3,9 @@ import type { MaybeRefOrGetter, Ref } from 'vue';
|
||||
import { isString } from '@robonen/stdlib';
|
||||
import { defaultNavigator } from '@/types';
|
||||
import type { ConfigurableNavigator } from '@/types';
|
||||
import { useSupported } from '@/composables/browser/useSupported';
|
||||
import { useSupported } from '@/composables/utilities/useSupported';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
import { useTimeoutFn } from '@/composables/utilities/useTimeoutFn';
|
||||
import { useTimeoutFn } from '@/composables/animation/useTimeoutFn';
|
||||
|
||||
/**
|
||||
* A value to copy: either a string or an (optionally async) getter that resolves to one.
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick } from 'vue';
|
||||
import type { UseClipboardItemsReturn } from '.';
|
||||
import { useClipboardItems } from '.';
|
||||
|
||||
function makeItems(label: string): ClipboardItems {
|
||||
// jsdom may lack ClipboardItem; a tagged plain object is enough for assertions.
|
||||
return [{ types: [label] } as unknown as ClipboardItem];
|
||||
}
|
||||
|
||||
function stubClipboard(readItems: ClipboardItems = makeItems('read')) {
|
||||
const write = vi.fn(async () => {});
|
||||
const read = vi.fn(async () => readItems);
|
||||
const navigator = {
|
||||
clipboard: { write, read },
|
||||
} as unknown as Navigator;
|
||||
return { navigator, write, read };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe(useClipboardItems, () => {
|
||||
it('reports support when the clipboard API exists', () => {
|
||||
const { navigator } = stubClipboard();
|
||||
const scope = effectScope();
|
||||
let clip: UseClipboardItemsReturn<false>;
|
||||
scope.run(() => {
|
||||
clip = useClipboardItems({ navigator });
|
||||
});
|
||||
expect(clip!.isSupported.value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('is not supported without the clipboard API', () => {
|
||||
const navigator = {} as unknown as Navigator;
|
||||
const scope = effectScope();
|
||||
let clip: UseClipboardItemsReturn<false>;
|
||||
scope.run(() => {
|
||||
clip = useClipboardItems({ navigator });
|
||||
});
|
||||
expect(clip!.isSupported.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('is not supported when navigator is undefined (SSR)', () => {
|
||||
const scope = effectScope();
|
||||
let clip: UseClipboardItemsReturn<false>;
|
||||
scope.run(() => {
|
||||
clip = useClipboardItems({ navigator: undefined });
|
||||
});
|
||||
expect(clip!.isSupported.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('copies items and sets copied flag', async () => {
|
||||
const { navigator, write } = stubClipboard();
|
||||
const items = makeItems('copy');
|
||||
const scope = effectScope();
|
||||
let clip: UseClipboardItemsReturn<false>;
|
||||
scope.run(() => {
|
||||
clip = useClipboardItems({ navigator });
|
||||
});
|
||||
|
||||
await clip!.copy(items);
|
||||
expect(write).toHaveBeenCalledWith(items);
|
||||
expect(clip!.content.value).toBe(items);
|
||||
expect(clip!.copied.value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('copies the configured source when called without args', async () => {
|
||||
const { navigator, write } = stubClipboard();
|
||||
const items = makeItems('source');
|
||||
const scope = effectScope();
|
||||
let clip: any;
|
||||
scope.run(() => {
|
||||
clip = useClipboardItems({ navigator, source: items });
|
||||
});
|
||||
|
||||
await clip.copy();
|
||||
expect(write).toHaveBeenCalledWith(items);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('copies a value resolved from an async getter', async () => {
|
||||
const { navigator, write } = stubClipboard();
|
||||
const items = makeItems('lazy');
|
||||
const scope = effectScope();
|
||||
let clip: UseClipboardItemsReturn<false>;
|
||||
scope.run(() => {
|
||||
clip = useClipboardItems({ navigator });
|
||||
});
|
||||
|
||||
await clip!.copy(async () => items);
|
||||
expect(write).toHaveBeenCalledWith(items);
|
||||
expect(clip!.content.value).toBe(items);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('skips when an async getter resolves to undefined', async () => {
|
||||
const { navigator, write } = stubClipboard();
|
||||
const scope = effectScope();
|
||||
let clip: UseClipboardItemsReturn<false>;
|
||||
scope.run(() => {
|
||||
clip = useClipboardItems({ navigator });
|
||||
});
|
||||
|
||||
await clip!.copy(async () => undefined);
|
||||
expect(write).not.toHaveBeenCalled();
|
||||
expect(clip!.copied.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('exposes copyPending around an in-flight async copy', async () => {
|
||||
const { navigator } = stubClipboard();
|
||||
const items = makeItems('done');
|
||||
const scope = effectScope();
|
||||
let clip: UseClipboardItemsReturn<false>;
|
||||
scope.run(() => {
|
||||
clip = useClipboardItems({ navigator });
|
||||
});
|
||||
|
||||
let release: (v: ClipboardItems) => void = () => {};
|
||||
const promise = clip!.copy(() => new Promise<ClipboardItems>((resolve) => {
|
||||
release = resolve;
|
||||
}));
|
||||
expect(clip!.copyPending.value).toBeTruthy();
|
||||
release(items);
|
||||
await promise;
|
||||
expect(clip!.copyPending.value).toBeFalsy();
|
||||
expect(clip!.content.value).toBe(items);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('ignores a stale async copy superseded by a newer one', async () => {
|
||||
const { navigator, write } = stubClipboard();
|
||||
const fastItems = makeItems('fast');
|
||||
const slowItems = makeItems('slow');
|
||||
const scope = effectScope();
|
||||
let clip: UseClipboardItemsReturn<false>;
|
||||
scope.run(() => {
|
||||
clip = useClipboardItems({ navigator });
|
||||
});
|
||||
|
||||
let releaseSlow: (v: ClipboardItems) => void = () => {};
|
||||
const slow = clip!.copy(() => new Promise<ClipboardItems>((resolve) => {
|
||||
releaseSlow = resolve;
|
||||
}));
|
||||
const fast = clip!.copy(async () => fastItems);
|
||||
await fast;
|
||||
releaseSlow(slowItems);
|
||||
await slow;
|
||||
|
||||
expect(clip!.content.value).toBe(fastItems);
|
||||
expect(write).toHaveBeenCalledTimes(1);
|
||||
expect(write).toHaveBeenCalledWith(fastItems);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does nothing when unsupported', async () => {
|
||||
const navigator = {} as unknown as Navigator;
|
||||
const scope = effectScope();
|
||||
let clip: UseClipboardItemsReturn<false>;
|
||||
scope.run(() => {
|
||||
clip = useClipboardItems({ navigator });
|
||||
});
|
||||
|
||||
await clip!.copy(makeItems('x'));
|
||||
expect(clip!.copied.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reads the clipboard via read()', async () => {
|
||||
const readItems = makeItems('from-clipboard');
|
||||
const { navigator, read } = stubClipboard(readItems);
|
||||
const scope = effectScope();
|
||||
let clip: UseClipboardItemsReturn<false>;
|
||||
scope.run(() => {
|
||||
clip = useClipboardItems({ navigator });
|
||||
});
|
||||
|
||||
await clip!.read();
|
||||
expect(read).toHaveBeenCalled();
|
||||
expect(clip!.content.value).toBe(readItems);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('syncs content on copy/cut events when read is enabled', async () => {
|
||||
const readItems = makeItems('synced');
|
||||
const { navigator, read } = stubClipboard(readItems);
|
||||
const scope = effectScope();
|
||||
let clip: UseClipboardItemsReturn<false>;
|
||||
scope.run(() => {
|
||||
clip = useClipboardItems({ navigator, read: true });
|
||||
});
|
||||
|
||||
globalThis.dispatchEvent(new Event('copy'));
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
expect(read).toHaveBeenCalled();
|
||||
expect(clip!.content.value).toBe(readItems);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('routes a rejected write to onError instead of throwing', async () => {
|
||||
const error = new Error('denied');
|
||||
const write = vi.fn(async () => {
|
||||
throw error;
|
||||
});
|
||||
const navigator = {
|
||||
clipboard: { write, read: vi.fn() },
|
||||
} as unknown as Navigator;
|
||||
const onError = vi.fn();
|
||||
const scope = effectScope();
|
||||
let clip: UseClipboardItemsReturn<false>;
|
||||
scope.run(() => {
|
||||
clip = useClipboardItems({ navigator, onError });
|
||||
});
|
||||
|
||||
await clip!.copy(makeItems('boom'));
|
||||
expect(onError).toHaveBeenCalledWith(error);
|
||||
expect(clip!.copied.value).toBeFalsy();
|
||||
expect(clip!.copyPending.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { shallowReadonly, shallowRef, toValue } from 'vue';
|
||||
import type { MaybeRefOrGetter, Ref } from 'vue';
|
||||
import { isFunction, noop } from '@robonen/stdlib';
|
||||
import { defaultNavigator } from '@/types';
|
||||
import type { ConfigurableNavigator } from '@/types';
|
||||
import { useSupported } from '@/composables/utilities/useSupported';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
import { useTimeoutFn } from '@/composables/animation/useTimeoutFn';
|
||||
|
||||
/**
|
||||
* A value to copy: either concrete `ClipboardItems` or an (optionally async)
|
||||
* getter that resolves to them.
|
||||
*/
|
||||
export type ClipboardItemsValue
|
||||
= | ClipboardItems
|
||||
| (() => Promise<ClipboardItems | undefined> | ClipboardItems | undefined);
|
||||
|
||||
export interface UseClipboardItemsOptions<Source> extends ConfigurableNavigator {
|
||||
/**
|
||||
* Sync `content` with the system clipboard by listening to copy/cut events
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
read?: boolean;
|
||||
|
||||
/**
|
||||
* Default source value to copy when `copy()` is called without an argument
|
||||
*/
|
||||
source?: Source;
|
||||
|
||||
/**
|
||||
* Milliseconds the `copied` flag stays `true` after a copy
|
||||
*
|
||||
* @default 1500
|
||||
*/
|
||||
copiedDuring?: number;
|
||||
|
||||
/**
|
||||
* Called when a read/write rejects, instead of throwing
|
||||
*
|
||||
* @default noop
|
||||
*/
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export interface UseClipboardItemsReturn<Optional extends boolean> {
|
||||
/**
|
||||
* Whether the async Clipboard API (with `ClipboardItem`) is available
|
||||
*/
|
||||
isSupported: Readonly<Ref<boolean>>;
|
||||
|
||||
/**
|
||||
* The current clipboard items (kept in sync when `read` is enabled)
|
||||
*/
|
||||
content: Readonly<Ref<ClipboardItems>>;
|
||||
|
||||
/**
|
||||
* `true` for `copiedDuring` ms after a successful copy
|
||||
*/
|
||||
copied: Readonly<Ref<boolean>>;
|
||||
|
||||
/**
|
||||
* `true` while an async `copy()` is in flight
|
||||
*/
|
||||
copyPending: Readonly<Ref<boolean>>;
|
||||
|
||||
/**
|
||||
* Copy clipboard items to the system clipboard
|
||||
*/
|
||||
copy: Optional extends true
|
||||
? (content?: ClipboardItemsValue) => Promise<void>
|
||||
: (content: ClipboardItemsValue) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Manually read the system clipboard into `content`
|
||||
*/
|
||||
read: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useClipboardItems
|
||||
* @category Browser
|
||||
* @description Reactive async Clipboard API with rich `ClipboardItem` support
|
||||
* (read/write images, HTML, and arbitrary MIME types — not just text).
|
||||
* SSR-safe; uses passive `copy`/`cut` listeners and guards stale async writes.
|
||||
*
|
||||
* @param {UseClipboardItemsOptions} [options={}] Options
|
||||
* @returns {UseClipboardItemsReturn} `isSupported`, `content`, `copied`, `copyPending`, `copy`, and `read`
|
||||
*
|
||||
* @example
|
||||
* const { content, copy, copied, isSupported } = useClipboardItems();
|
||||
* copy([new ClipboardItem({ 'text/plain': new Blob(['hello'], { type: 'text/plain' }) })]);
|
||||
*
|
||||
* @example
|
||||
* // Copy a lazily/asynchronously resolved value, kept in sync with the system clipboard
|
||||
* const { content } = useClipboardItems({ read: true });
|
||||
* copy(async () => buildClipboardItems());
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useClipboardItems(options?: UseClipboardItemsOptions<undefined>): UseClipboardItemsReturn<false>;
|
||||
export function useClipboardItems(options: UseClipboardItemsOptions<MaybeRefOrGetter<ClipboardItems>>): UseClipboardItemsReturn<true>;
|
||||
export function useClipboardItems(
|
||||
options: UseClipboardItemsOptions<MaybeRefOrGetter<ClipboardItems> | undefined> = {},
|
||||
): UseClipboardItemsReturn<boolean> {
|
||||
const {
|
||||
navigator = defaultNavigator,
|
||||
read = false,
|
||||
source,
|
||||
copiedDuring = 1500,
|
||||
onError = noop,
|
||||
} = options;
|
||||
|
||||
const isSupported = useSupported(() => navigator && 'clipboard' in navigator);
|
||||
|
||||
const content = shallowRef<ClipboardItems>([]);
|
||||
const copied = shallowRef(false);
|
||||
const copyPending = shallowRef(false);
|
||||
|
||||
// Guards against a slow async copy clobbering the result of a newer one
|
||||
let lastResolveId = 0;
|
||||
|
||||
const timeout = useTimeoutFn(() => {
|
||||
copied.value = false;
|
||||
}, copiedDuring, { immediate: false });
|
||||
|
||||
async function updateContent(): Promise<void> {
|
||||
if (!isSupported.value)
|
||||
return;
|
||||
|
||||
try {
|
||||
content.value = await navigator!.clipboard.read();
|
||||
}
|
||||
catch (error) {
|
||||
onError(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (isSupported.value && read)
|
||||
useEventListener(['copy', 'cut'], updateContent, { passive: true });
|
||||
|
||||
async function copy(value: ClipboardItemsValue | undefined = toValue(source)): Promise<void> {
|
||||
if (!isSupported.value || value === null || value === undefined)
|
||||
return;
|
||||
|
||||
copyPending.value = true;
|
||||
|
||||
try {
|
||||
let resolved: ClipboardItems | undefined;
|
||||
|
||||
if (isFunction(value)) {
|
||||
const currentId = ++lastResolveId;
|
||||
resolved = await value();
|
||||
|
||||
// Drop a stale async resolution superseded by a newer copy
|
||||
if (resolved === null || resolved === undefined || currentId !== lastResolveId)
|
||||
return;
|
||||
}
|
||||
else {
|
||||
resolved = value;
|
||||
}
|
||||
|
||||
await navigator!.clipboard.write(resolved);
|
||||
|
||||
content.value = resolved;
|
||||
copied.value = true;
|
||||
timeout.start();
|
||||
}
|
||||
catch (error) {
|
||||
onError(error);
|
||||
}
|
||||
finally {
|
||||
copyPending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
content: shallowReadonly(content),
|
||||
copied: shallowReadonly(copied),
|
||||
copyPending: shallowReadonly(copyPending),
|
||||
copy,
|
||||
read: updateContent,
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { noop } from '@robonen/stdlib';
|
||||
import type { Ref } from 'vue';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { useSupported } from '@/composables/browser/useSupported';
|
||||
import { useSupported } from '@/composables/utilities/useSupported';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
|
||||
|
||||
@@ -186,12 +186,12 @@ export function useColorMode<T extends string = BasicColorMode>(
|
||||
let attributeToChange: { key: string; value: string } | null = null;
|
||||
|
||||
if (attr === 'class') {
|
||||
const next = value.split(/\s/g);
|
||||
const next = new Set(value.split(/\s/g));
|
||||
|
||||
// Toggle only the classes this composable owns (derived from `modes`),
|
||||
// so unrelated classes on the element are left untouched.
|
||||
for (const owned of Object.values<string>(modes).flatMap(mode => (mode || '').split(/\s/g)).filter(Boolean)) {
|
||||
if (next.includes(owned))
|
||||
if (next.has(owned))
|
||||
classesToAdd.add(owned);
|
||||
else
|
||||
classesToRemove.add(owned);
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick, ref } from 'vue';
|
||||
import { useCssVar } from '.';
|
||||
|
||||
let mutationInstances: Array<{ cb: MutationCallback; observe: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn> }> = [];
|
||||
|
||||
class StubMutationObserver {
|
||||
observe = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
takeRecords = vi.fn(() => []);
|
||||
cb: MutationCallback;
|
||||
constructor(cb: MutationCallback) {
|
||||
this.cb = cb;
|
||||
mutationInstances.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
describe(useCssVar, () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
mutationInstances = [];
|
||||
});
|
||||
|
||||
it('reads the existing custom property from the target', () => {
|
||||
const el = document.createElement('div');
|
||||
el.style.setProperty('--color', 'red');
|
||||
document.body.appendChild(el);
|
||||
|
||||
const scope = effectScope();
|
||||
let color: ReturnType<typeof useCssVar>;
|
||||
scope.run(() => {
|
||||
color = useCssVar('--color', el);
|
||||
});
|
||||
|
||||
expect(color!.value).toBe('red');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('writes the property to the element when set', async () => {
|
||||
const el = document.createElement('div');
|
||||
document.body.appendChild(el);
|
||||
|
||||
const scope = effectScope();
|
||||
let color: ReturnType<typeof useCssVar>;
|
||||
scope.run(() => {
|
||||
color = useCssVar('--color', el);
|
||||
});
|
||||
|
||||
color!.value = 'blue';
|
||||
await nextTick();
|
||||
|
||||
expect(el.style.getPropertyValue('--color')).toBe('blue');
|
||||
expect(color!.value).toBe('blue');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('removes the property when set to null', () => {
|
||||
const el = document.createElement('div');
|
||||
el.style.setProperty('--color', 'green');
|
||||
document.body.appendChild(el);
|
||||
|
||||
const scope = effectScope();
|
||||
let color: ReturnType<typeof useCssVar>;
|
||||
scope.run(() => {
|
||||
color = useCssVar('--color', el);
|
||||
});
|
||||
|
||||
color!.value = null;
|
||||
|
||||
expect(el.style.getPropertyValue('--color')).toBe('');
|
||||
expect(color!.value).toBeNull();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('falls back to initialValue when the property is unset', () => {
|
||||
const el = document.createElement('div');
|
||||
document.body.appendChild(el);
|
||||
|
||||
const scope = effectScope();
|
||||
let color: ReturnType<typeof useCssVar>;
|
||||
scope.run(() => {
|
||||
color = useCssVar('--missing', el, { initialValue: 'gray' });
|
||||
});
|
||||
|
||||
expect(color!.value).toBe('gray');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('defaults the target to document.documentElement', () => {
|
||||
document.documentElement.style.setProperty('--root-var', 'rootval');
|
||||
|
||||
const scope = effectScope();
|
||||
let v: ReturnType<typeof useCssVar>;
|
||||
scope.run(() => {
|
||||
v = useCssVar('--root-var');
|
||||
});
|
||||
|
||||
expect(v!.value).toBe('rootval');
|
||||
v!.value = 'changed';
|
||||
expect(document.documentElement.style.getPropertyValue('--root-var')).toBe('changed');
|
||||
|
||||
document.documentElement.style.removeProperty('--root-var');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('removes the old property and re-reads when the prop name changes', async () => {
|
||||
const el = document.createElement('div');
|
||||
el.style.setProperty('--a', 'one');
|
||||
el.style.setProperty('--b', 'two');
|
||||
document.body.appendChild(el);
|
||||
|
||||
const prop = ref('--a');
|
||||
const scope = effectScope();
|
||||
let v: ReturnType<typeof useCssVar>;
|
||||
scope.run(() => {
|
||||
v = useCssVar(prop, el);
|
||||
});
|
||||
|
||||
expect(v!.value).toBe('one');
|
||||
|
||||
prop.value = '--b';
|
||||
await nextTick();
|
||||
|
||||
expect(el.style.getPropertyValue('--a')).toBe('');
|
||||
expect(v!.value).toBe('two');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('re-reads when a reactive target changes', async () => {
|
||||
const a = document.createElement('div');
|
||||
a.style.setProperty('--c', 'fromA');
|
||||
const b = document.createElement('div');
|
||||
b.style.setProperty('--c', 'fromB');
|
||||
document.body.append(a, b);
|
||||
|
||||
const target = ref<HTMLElement>(a);
|
||||
const scope = effectScope();
|
||||
let v: ReturnType<typeof useCssVar>;
|
||||
scope.run(() => {
|
||||
v = useCssVar('--c', target);
|
||||
});
|
||||
|
||||
expect(v!.value).toBe('fromA');
|
||||
|
||||
target.value = b;
|
||||
await nextTick();
|
||||
|
||||
expect(v!.value).toBe('fromB');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('attaches a MutationObserver only when observe is true', () => {
|
||||
vi.stubGlobal('MutationObserver', StubMutationObserver);
|
||||
const el = document.createElement('div');
|
||||
document.body.appendChild(el);
|
||||
|
||||
const scopeOff = effectScope();
|
||||
scopeOff.run(() => useCssVar('--x', el));
|
||||
expect(mutationInstances).toHaveLength(0);
|
||||
scopeOff.stop();
|
||||
|
||||
const scopeOn = effectScope();
|
||||
scopeOn.run(() => useCssVar('--x', el, { observe: true }));
|
||||
expect(mutationInstances).toHaveLength(1);
|
||||
expect(mutationInstances[0]!.observe).toHaveBeenCalledWith(
|
||||
el,
|
||||
expect.objectContaining({ attributeFilter: ['style', 'class'] }),
|
||||
);
|
||||
scopeOn.stop();
|
||||
});
|
||||
|
||||
it('updates the ref when an observed mutation fires', () => {
|
||||
vi.stubGlobal('MutationObserver', StubMutationObserver);
|
||||
const el = document.createElement('div');
|
||||
el.style.setProperty('--y', 'initial');
|
||||
document.body.appendChild(el);
|
||||
|
||||
const scope = effectScope();
|
||||
let v: ReturnType<typeof useCssVar>;
|
||||
scope.run(() => {
|
||||
v = useCssVar('--y', el, { observe: true });
|
||||
});
|
||||
|
||||
expect(v!.value).toBe('initial');
|
||||
|
||||
// Simulate an external change followed by a mutation record.
|
||||
el.style.setProperty('--y', 'external');
|
||||
mutationInstances[0]!.cb([{ type: 'attributes' } as MutationRecord], mutationInstances[0] as unknown as MutationObserver);
|
||||
|
||||
expect(v!.value).toBe('external');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not throw and keeps initialValue under SSR (no window)', () => {
|
||||
const scope = effectScope();
|
||||
let v: ReturnType<typeof useCssVar>;
|
||||
scope.run(() => {
|
||||
v = useCssVar('--ssr', null, {
|
||||
initialValue: 'fallback',
|
||||
window: undefined as unknown as Window,
|
||||
});
|
||||
});
|
||||
|
||||
expect(v!.value).toBe('fallback');
|
||||
// Writing is a no-op on the (missing) element but still updates the store.
|
||||
v!.value = 'next';
|
||||
expect(v!.value).toBe('next');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('uses a custom window from options for getComputedStyle', () => {
|
||||
const el = document.createElement('div');
|
||||
document.body.appendChild(el);
|
||||
|
||||
const getComputedStyle = vi.fn(() => ({
|
||||
getPropertyValue: () => ' spaced ',
|
||||
})) as unknown as Window['getComputedStyle'];
|
||||
|
||||
const fakeWindow = {
|
||||
getComputedStyle,
|
||||
document: { documentElement: el },
|
||||
} as unknown as Window;
|
||||
|
||||
const scope = effectScope();
|
||||
let v: ReturnType<typeof useCssVar>;
|
||||
scope.run(() => {
|
||||
v = useCssVar('--z', el, { window: fakeWindow });
|
||||
});
|
||||
|
||||
expect(getComputedStyle).toHaveBeenCalledWith(el);
|
||||
expect(v!.value).toBe('spaced');
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { computed, shallowRef, toValue, watch } from 'vue';
|
||||
import type { ComputedRef, MaybeRefOrGetter, WritableComputedRef } from 'vue';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
||||
import { unrefElement } from '@/composables/component/unrefElement';
|
||||
import { useMutationObserver } from '@/composables/elements/useMutationObserver';
|
||||
|
||||
export interface UseCssVarOptions extends ConfigurableWindow {
|
||||
/**
|
||||
* Value used before the variable resolves (and the fallback when the
|
||||
* computed value is empty).
|
||||
*/
|
||||
initialValue?: string;
|
||||
/**
|
||||
* Watch the target with a `MutationObserver` (filtered to `style`/`class`)
|
||||
* so the ref reflects external changes to the variable.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
observe?: boolean;
|
||||
}
|
||||
|
||||
export interface UseCssVarReturn extends WritableComputedRef<string | null | undefined> {}
|
||||
|
||||
/**
|
||||
* @name useCssVar
|
||||
* @category Browser
|
||||
* @description Read and write a CSS custom property on an element as a reactive ref.
|
||||
* Defaults to `document.documentElement`. Set `observe` to react to external
|
||||
* changes via a `MutationObserver`.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<string | null | undefined>} prop The CSS variable name (e.g. `--color`)
|
||||
* @param {MaybeComputedElementRef} [target] Element to read/write the variable on (defaults to `documentElement`)
|
||||
* @param {UseCssVarOptions} [options={}] `initialValue`, `observe`, and a configurable `window`
|
||||
* @returns {UseCssVarReturn} A writable ref; reading returns the current value, writing updates the property
|
||||
*
|
||||
* @example
|
||||
* const color = useCssVar('--color', el);
|
||||
* color.value = 'red';
|
||||
*
|
||||
* @example
|
||||
* const theme = useCssVar('--theme', null, { initialValue: 'light', observe: true });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useCssVar(
|
||||
prop: MaybeRefOrGetter<string | null | undefined>,
|
||||
target?: MaybeComputedElementRef,
|
||||
options: UseCssVarOptions = {},
|
||||
): UseCssVarReturn {
|
||||
const { window = defaultWindow, initialValue, observe = false } = options;
|
||||
|
||||
// Backing store: only mutated on explicit reads / observed changes,
|
||||
// so consumers reading `.value` never pay for `getComputedStyle`.
|
||||
const store = shallowRef<string | null | undefined>(initialValue);
|
||||
|
||||
const elRef: ComputedRef<HTMLElement | SVGElement | undefined> = computed(
|
||||
() => (unrefElement(target) as HTMLElement | SVGElement | undefined) ?? window?.document?.documentElement,
|
||||
);
|
||||
|
||||
const read = (): void => {
|
||||
const el = elRef.value;
|
||||
const key = toValue(prop);
|
||||
|
||||
if (!el || !window || !key)
|
||||
return;
|
||||
|
||||
const value = window.getComputedStyle(el).getPropertyValue(key)?.trim();
|
||||
store.value = value || store.value || initialValue;
|
||||
};
|
||||
|
||||
const write = (value: string | null | undefined): void => {
|
||||
const el = elRef.value;
|
||||
const key = toValue(prop);
|
||||
|
||||
store.value = value;
|
||||
|
||||
if (!el?.style || !key)
|
||||
return;
|
||||
|
||||
if (value === null || value === undefined)
|
||||
el.style.removeProperty(key);
|
||||
else
|
||||
el.style.setProperty(key, value);
|
||||
};
|
||||
|
||||
if (observe) {
|
||||
useMutationObserver(elRef, read, {
|
||||
attributeFilter: ['style', 'class'],
|
||||
window,
|
||||
});
|
||||
}
|
||||
|
||||
// Single watcher: when the element or prop changes, drop the old custom
|
||||
// property and re-read the current value.
|
||||
watch(
|
||||
() => [elRef.value, toValue(prop)] as const,
|
||||
([el, key], old) => {
|
||||
const [oldEl, oldKey] = old ?? [];
|
||||
if (oldEl?.style && oldKey && (oldEl !== el || oldKey !== key))
|
||||
oldEl.style.removeProperty(oldKey);
|
||||
read();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
return computed<string | null | undefined>({
|
||||
get: () => store.value,
|
||||
set: write,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick } from 'vue';
|
||||
import { useDark } from '.';
|
||||
|
||||
type Listener = (event: { matches: boolean }) => void;
|
||||
|
||||
interface StubMql {
|
||||
readonly matches: boolean;
|
||||
media: string;
|
||||
addEventListener: (type: string, cb: Listener) => void;
|
||||
removeEventListener: (type: string, cb: Listener) => void;
|
||||
dispatch: (value: boolean) => void;
|
||||
}
|
||||
|
||||
function makeMql(initialMatches: boolean, media = ''): StubMql {
|
||||
const listeners = new Set<Listener>();
|
||||
let matches = initialMatches;
|
||||
|
||||
return {
|
||||
get matches() {
|
||||
return matches;
|
||||
},
|
||||
media,
|
||||
addEventListener: (_: string, cb: Listener) => listeners.add(cb),
|
||||
removeEventListener: (_: string, cb: Listener) => listeners.delete(cb),
|
||||
dispatch(value: boolean) {
|
||||
matches = value;
|
||||
for (const cb of listeners) cb({ matches: value });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stub `window` that reuses the real jsdom `document` (so DOM updates
|
||||
* applied to `<html>` are observable) but with a controllable `matchMedia` for
|
||||
* `prefers-color-scheme: dark`, an isolated in-memory `localStorage`, and a
|
||||
* `getComputedStyle` shim for the transition-disabling reflow.
|
||||
*/
|
||||
function makeWindow(prefersDark: StubMql) {
|
||||
const map = new Map<string, string>();
|
||||
|
||||
const storage: Storage = {
|
||||
getItem: (key: string) => (map.has(key) ? map.get(key)! : null),
|
||||
setItem: (key: string, value: string) => { map.set(key, String(value)); },
|
||||
removeItem: (key: string) => { map.delete(key); },
|
||||
clear: () => map.clear(),
|
||||
key: (index: number) => [...map.keys()][index] ?? null,
|
||||
get length() {
|
||||
return map.size;
|
||||
},
|
||||
};
|
||||
|
||||
const win = {
|
||||
document: globalThis.document,
|
||||
matchMedia: vi.fn((query: string) =>
|
||||
query.includes('dark') ? prefersDark : makeMql(false, query)),
|
||||
localStorage: storage,
|
||||
getComputedStyle: () => ({ opacity: '1' }),
|
||||
dispatchEvent: () => true,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
} as unknown as Window & typeof globalThis;
|
||||
|
||||
return { win, storage, map };
|
||||
}
|
||||
|
||||
function reset() {
|
||||
document.documentElement.className = '';
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
|
||||
describe(useDark, () => {
|
||||
beforeEach(() => {
|
||||
reset();
|
||||
// Ensure module-captured defaultWindow.matchMedia is undefined so the
|
||||
// composable must use the injected window.
|
||||
vi.stubGlobal('matchMedia', undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
reset();
|
||||
});
|
||||
|
||||
it('reflects the system preference in auto mode (dark)', async () => {
|
||||
const prefersDark = makeMql(true);
|
||||
const { win } = makeWindow(prefersDark);
|
||||
const scope = effectScope();
|
||||
let isDark: ReturnType<typeof useDark>;
|
||||
|
||||
scope.run(() => {
|
||||
isDark = useDark({ window: win });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(isDark!.value).toBeTruthy();
|
||||
expect(document.documentElement.classList.contains('dark')).toBeTruthy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reflects the system preference in auto mode (light)', async () => {
|
||||
const prefersDark = makeMql(false);
|
||||
const { win } = makeWindow(prefersDark);
|
||||
const scope = effectScope();
|
||||
let isDark: ReturnType<typeof useDark>;
|
||||
|
||||
scope.run(() => {
|
||||
isDark = useDark({ window: win });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(isDark!.value).toBeFalsy();
|
||||
// Default valueLight is '' so no light class is applied.
|
||||
expect(document.documentElement.classList.contains('dark')).toBeFalsy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('writing true while the system prefers light applies the dark class', async () => {
|
||||
const prefersDark = makeMql(false);
|
||||
const { win } = makeWindow(prefersDark);
|
||||
const scope = effectScope();
|
||||
let isDark: ReturnType<typeof useDark>;
|
||||
|
||||
scope.run(() => {
|
||||
isDark = useDark({ window: win });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(isDark!.value).toBeFalsy();
|
||||
|
||||
isDark!.value = true;
|
||||
await nextTick();
|
||||
|
||||
expect(isDark!.value).toBeTruthy();
|
||||
expect(document.documentElement.classList.contains('dark')).toBeTruthy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('writing true while the system prefers dark falls back to auto', async () => {
|
||||
const prefersDark = makeMql(true);
|
||||
const { win, storage } = makeWindow(prefersDark);
|
||||
const scope = effectScope();
|
||||
let isDark: ReturnType<typeof useDark>;
|
||||
|
||||
scope.run(() => {
|
||||
// Start from an explicit light value so the store is not already auto.
|
||||
isDark = useDark({ window: win });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
isDark!.value = false;
|
||||
await nextTick();
|
||||
expect(storage.getItem('vuetools-color-scheme')).toBe('light');
|
||||
|
||||
// System prefers dark, so requesting dark should resolve to 'auto'.
|
||||
isDark!.value = true;
|
||||
await nextTick();
|
||||
|
||||
expect(isDark!.value).toBeTruthy();
|
||||
expect(storage.getItem('vuetools-color-scheme')).toBe('auto');
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('writing false while the system prefers dark falls back to auto', async () => {
|
||||
const prefersDark = makeMql(false);
|
||||
const { win, storage } = makeWindow(prefersDark);
|
||||
const scope = effectScope();
|
||||
let isDark: ReturnType<typeof useDark>;
|
||||
|
||||
scope.run(() => {
|
||||
isDark = useDark({ window: win });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
// System prefers light, so requesting light resolves to 'auto'.
|
||||
isDark!.value = false;
|
||||
await nextTick();
|
||||
|
||||
expect(isDark!.value).toBeFalsy();
|
||||
expect(storage.getItem('vuetools-color-scheme')).toBe('auto');
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reacts to system preference changes while in auto mode', async () => {
|
||||
const prefersDark = makeMql(false);
|
||||
const { win } = makeWindow(prefersDark);
|
||||
const scope = effectScope();
|
||||
let isDark: ReturnType<typeof useDark>;
|
||||
|
||||
scope.run(() => {
|
||||
isDark = useDark({ window: win });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(isDark!.value).toBeFalsy();
|
||||
|
||||
prefersDark.dispatch(true);
|
||||
await nextTick();
|
||||
|
||||
expect(isDark!.value).toBeTruthy();
|
||||
expect(document.documentElement.classList.contains('dark')).toBeTruthy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('honours custom valueDark / valueLight on a custom attribute', async () => {
|
||||
const prefersDark = makeMql(false);
|
||||
const { win } = makeWindow(prefersDark);
|
||||
const scope = effectScope();
|
||||
let isDark: ReturnType<typeof useDark>;
|
||||
|
||||
scope.run(() => {
|
||||
isDark = useDark({
|
||||
window: win,
|
||||
attribute: 'data-theme',
|
||||
valueDark: 'night',
|
||||
valueLight: 'day',
|
||||
});
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('day');
|
||||
|
||||
isDark!.value = true;
|
||||
await nextTick();
|
||||
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('night');
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('persists to a custom storageKey', async () => {
|
||||
const prefersDark = makeMql(false);
|
||||
const { win, storage } = makeWindow(prefersDark);
|
||||
const scope = effectScope();
|
||||
let isDark: ReturnType<typeof useDark>;
|
||||
|
||||
scope.run(() => {
|
||||
isDark = useDark({ window: win, storageKey: 'my-dark' });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
isDark!.value = true;
|
||||
await nextTick();
|
||||
|
||||
expect(storage.getItem('my-dark')).toBe('dark');
|
||||
expect(storage.getItem('vuetools-color-scheme')).toBeNull();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('uses a custom storage backend', async () => {
|
||||
const prefersDark = makeMql(false);
|
||||
const { win } = makeWindow(prefersDark);
|
||||
const map = new Map<string, string>();
|
||||
const storage: Storage = {
|
||||
getItem: (key: string) => (map.has(key) ? map.get(key)! : null),
|
||||
setItem: (key: string, value: string) => { map.set(key, String(value)); },
|
||||
removeItem: (key: string) => { map.delete(key); },
|
||||
clear: () => map.clear(),
|
||||
key: (index: number) => [...map.keys()][index] ?? null,
|
||||
get length() {
|
||||
return map.size;
|
||||
},
|
||||
};
|
||||
const scope = effectScope();
|
||||
let isDark: ReturnType<typeof useDark>;
|
||||
|
||||
scope.run(() => {
|
||||
isDark = useDark({ window: win, storage });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
isDark!.value = true;
|
||||
await nextTick();
|
||||
|
||||
expect(map.get('vuetools-color-scheme')).toBe('dark');
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('invokes a custom onChanged handler with the boolean state', async () => {
|
||||
const prefersDark = makeMql(true);
|
||||
const { win } = makeWindow(prefersDark);
|
||||
const onChanged = vi.fn();
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
useDark({ window: win, onChanged });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(onChanged).toHaveBeenCalled();
|
||||
const [boolValue, handler, mode] = onChanged.mock.calls[0]!;
|
||||
expect(boolValue).toBeTruthy();
|
||||
expect(typeof handler).toBe('function');
|
||||
expect(mode).toBe('dark');
|
||||
// Default handler suppressed: no class applied.
|
||||
expect(document.documentElement.classList.contains('dark')).toBeFalsy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not throw on the SSR/unsupported path (no window)', async () => {
|
||||
const scope = effectScope();
|
||||
let isDark: ReturnType<typeof useDark>;
|
||||
|
||||
expect(() => {
|
||||
scope.run(() => {
|
||||
isDark = useDark({ window: undefined });
|
||||
});
|
||||
}).not.toThrow();
|
||||
await nextTick();
|
||||
|
||||
// System detection unavailable -> defaults to light -> isDark false.
|
||||
expect(isDark!.value).toBeFalsy();
|
||||
|
||||
// Still writable in memory.
|
||||
isDark!.value = true;
|
||||
await nextTick();
|
||||
expect(isDark!.value).toBeTruthy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { computed } from 'vue';
|
||||
import type { WritableComputedRef } from 'vue';
|
||||
import { useColorMode } from '@/composables/browser/useColorMode';
|
||||
import type { BasicColorSchema, UseColorModeOptions } from '@/composables/browser/useColorMode';
|
||||
|
||||
export interface UseDarkOptions extends Omit<UseColorModeOptions<BasicColorSchema>, 'modes' | 'onChanged'> {
|
||||
/**
|
||||
* Value applied to the target element when `isDark` is `true`.
|
||||
*
|
||||
* @default 'dark'
|
||||
*/
|
||||
valueDark?: string;
|
||||
|
||||
/**
|
||||
* Value applied to the target element when `isDark` is `false`.
|
||||
*
|
||||
* @default ''
|
||||
*/
|
||||
valueLight?: string;
|
||||
|
||||
/**
|
||||
* Custom handler called whenever the resolved mode changes. When specified,
|
||||
* the default DOM update is overridden (call `defaultHandler` to keep it).
|
||||
*
|
||||
* @default undefined
|
||||
*/
|
||||
onChanged?: (isDark: boolean, defaultHandler: (mode: BasicColorSchema) => void, mode: BasicColorSchema) => void;
|
||||
}
|
||||
|
||||
export type UseDarkReturn = WritableComputedRef<boolean>;
|
||||
|
||||
/**
|
||||
* @name useDark
|
||||
* @category Browser
|
||||
* @description Reactive dark mode boolean with system detection and storage
|
||||
* persistence, built on `useColorMode`. Writing `false` while the system
|
||||
* already prefers light (or `true` while it prefers dark) falls back to
|
||||
* `'auto'`, so the mode keeps tracking the OS preference.
|
||||
*
|
||||
* @param {UseDarkOptions} [options={}] Options
|
||||
* @returns {UseDarkReturn} A writable boolean ref; `true` when dark mode is active
|
||||
*
|
||||
* @example
|
||||
* const isDark = useDark();
|
||||
* isDark.value = true;
|
||||
*
|
||||
* @example
|
||||
* // Toggle a data attribute instead of a class
|
||||
* const isDark = useDark({
|
||||
* attribute: 'data-theme',
|
||||
* valueDark: 'dark',
|
||||
* valueLight: 'light',
|
||||
* });
|
||||
*
|
||||
* @example
|
||||
* import { useToggle } from '@/composables/state/useToggle';
|
||||
*
|
||||
* const isDark = useDark();
|
||||
* const toggleDark = useToggle(isDark);
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useDark(options: UseDarkOptions = {}): UseDarkReturn {
|
||||
const {
|
||||
valueDark = 'dark',
|
||||
valueLight = '',
|
||||
} = options;
|
||||
|
||||
const mode = useColorMode({
|
||||
...options,
|
||||
onChanged: (resolved, defaultHandler) => {
|
||||
if (options.onChanged)
|
||||
options.onChanged(resolved === 'dark', defaultHandler, resolved);
|
||||
else
|
||||
defaultHandler(resolved);
|
||||
},
|
||||
modes: {
|
||||
dark: valueDark,
|
||||
light: valueLight,
|
||||
},
|
||||
});
|
||||
|
||||
const isDark = computed<boolean>({
|
||||
get() {
|
||||
return mode.state.value === 'dark';
|
||||
},
|
||||
set(value) {
|
||||
const next = value ? 'dark' : 'light';
|
||||
|
||||
// When the requested state already matches the system preference, fall
|
||||
// back to `'auto'` so the mode keeps following the OS going forward.
|
||||
mode.value = mode.system.value === next ? 'auto' : next;
|
||||
},
|
||||
});
|
||||
|
||||
return isDark;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick } from 'vue';
|
||||
import { useDocumentPiP } from '.';
|
||||
import type { DocumentPictureInPictureOptions } from '.';
|
||||
|
||||
/**
|
||||
* Build a fake Picture-in-Picture `Window` that records its listeners and
|
||||
* `close()` call. Passed through options so it reaches the import-time-captured
|
||||
* `defaultWindow` substitute (see test gotcha).
|
||||
*/
|
||||
function createPipWindow() {
|
||||
const listeners = new Map<string, EventListener>();
|
||||
|
||||
const pip = {
|
||||
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
listeners.set(type, listener);
|
||||
}),
|
||||
close: vi.fn(),
|
||||
} as unknown as Window;
|
||||
|
||||
function firePagehide() {
|
||||
listeners.get('pagehide')?.(new Event('pagehide'));
|
||||
}
|
||||
|
||||
return { pip, firePagehide, close: pip.close as ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
function createWindow(pip: Window) {
|
||||
const requestWindow = vi.fn(async (_options?: DocumentPictureInPictureOptions) => pip);
|
||||
const win = {
|
||||
documentPictureInPicture: { window: null, requestWindow },
|
||||
} as unknown as Window & typeof globalThis;
|
||||
|
||||
return { window: win, requestWindow };
|
||||
}
|
||||
|
||||
describe(useDocumentPiP, () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('reports supported when documentPictureInPicture exists on window', () => {
|
||||
const scope = effectScope();
|
||||
const { pip } = createPipWindow();
|
||||
const { window } = createWindow(pip);
|
||||
|
||||
let result: ReturnType<typeof useDocumentPiP>;
|
||||
scope.run(() => {
|
||||
result = useDocumentPiP({ window });
|
||||
});
|
||||
|
||||
expect(result!.isSupported.value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reports unsupported when the API is absent', () => {
|
||||
const scope = effectScope();
|
||||
const win = {} as unknown as Window & typeof globalThis;
|
||||
|
||||
let result: ReturnType<typeof useDocumentPiP>;
|
||||
scope.run(() => {
|
||||
result = useDocumentPiP({ window: win });
|
||||
});
|
||||
|
||||
expect(result!.isSupported.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('is SSR safe when window is undefined', async () => {
|
||||
const scope = effectScope();
|
||||
|
||||
let result: ReturnType<typeof useDocumentPiP>;
|
||||
scope.run(() => {
|
||||
result = useDocumentPiP({ window: undefined as unknown as Window });
|
||||
});
|
||||
|
||||
expect(result!.isSupported.value).toBeFalsy();
|
||||
await expect(result!.open()).resolves.toBeUndefined();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('opens a PiP window, tracks it, and forwards options', async () => {
|
||||
const scope = effectScope();
|
||||
const { pip } = createPipWindow();
|
||||
const { window, requestWindow } = createWindow(pip);
|
||||
|
||||
let result: ReturnType<typeof useDocumentPiP>;
|
||||
scope.run(() => {
|
||||
result = useDocumentPiP({ window });
|
||||
});
|
||||
|
||||
const returned = await result!.open({ width: 320, height: 240 });
|
||||
await nextTick();
|
||||
|
||||
expect(requestWindow).toHaveBeenCalledWith({ width: 320, height: 240 });
|
||||
expect(returned).toBe(pip);
|
||||
expect(result!.pipWindow.value).toBe(pip);
|
||||
expect(result!.isOpen.value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('clears the reference when the PiP window emits pagehide', async () => {
|
||||
const scope = effectScope();
|
||||
const { pip, firePagehide } = createPipWindow();
|
||||
const { window } = createWindow(pip);
|
||||
|
||||
let result: ReturnType<typeof useDocumentPiP>;
|
||||
scope.run(() => {
|
||||
result = useDocumentPiP({ window });
|
||||
});
|
||||
|
||||
await result!.open();
|
||||
expect(result!.isOpen.value).toBeTruthy();
|
||||
|
||||
firePagehide();
|
||||
await nextTick();
|
||||
|
||||
expect(result!.pipWindow.value).toBeNull();
|
||||
expect(result!.isOpen.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('close() closes the window and clears state', async () => {
|
||||
const scope = effectScope();
|
||||
const { pip, close } = createPipWindow();
|
||||
const { window } = createWindow(pip);
|
||||
|
||||
let result: ReturnType<typeof useDocumentPiP>;
|
||||
scope.run(() => {
|
||||
result = useDocumentPiP({ window });
|
||||
});
|
||||
|
||||
await result!.open();
|
||||
result!.close();
|
||||
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(result!.pipWindow.value).toBeNull();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('closes the PiP window when the scope is disposed', async () => {
|
||||
const scope = effectScope();
|
||||
const { pip, close } = createPipWindow();
|
||||
const { window } = createWindow(pip);
|
||||
|
||||
let result: ReturnType<typeof useDocumentPiP>;
|
||||
scope.run(() => {
|
||||
result = useDocumentPiP({ window });
|
||||
});
|
||||
|
||||
await result!.open();
|
||||
scope.stop();
|
||||
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stores the error and invokes onError when open() rejects', async () => {
|
||||
const scope = effectScope();
|
||||
const error = new DOMException('gesture required', 'NotAllowedError');
|
||||
const requestWindow = vi.fn(async () => {
|
||||
throw error;
|
||||
});
|
||||
const win = {
|
||||
documentPictureInPicture: { window: null, requestWindow },
|
||||
} as unknown as Window & typeof globalThis;
|
||||
const onError = vi.fn();
|
||||
|
||||
let result: ReturnType<typeof useDocumentPiP>;
|
||||
scope.run(() => {
|
||||
result = useDocumentPiP({ window: win, onError });
|
||||
});
|
||||
|
||||
await expect(result!.open()).resolves.toBeUndefined();
|
||||
expect(onError).toHaveBeenCalledWith(error);
|
||||
expect(result!.error.value).toBe(error);
|
||||
expect(result!.isOpen.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { computed, shallowRef } from 'vue';
|
||||
import type { ComputedRef, ShallowRef } from 'vue';
|
||||
import { noop } from '@robonen/stdlib';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { useSupported } from '@/composables/utilities/useSupported';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
|
||||
export interface DocumentPictureInPictureOptions {
|
||||
/**
|
||||
* The initial width of the Picture-in-Picture window, in pixels.
|
||||
*/
|
||||
width?: number;
|
||||
|
||||
/**
|
||||
* The initial height of the Picture-in-Picture window, in pixels.
|
||||
*/
|
||||
height?: number;
|
||||
|
||||
/**
|
||||
* Hide the "back to tab" button in the Picture-in-Picture window.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
disallowReturnToOpener?: boolean;
|
||||
|
||||
/**
|
||||
* Open the window in its default position/size rather than reusing the last one.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
preferInitialWindowPlacement?: boolean;
|
||||
}
|
||||
|
||||
interface DocumentPictureInPicture {
|
||||
readonly window: Window | null;
|
||||
requestWindow: (options?: DocumentPictureInPictureOptions) => Promise<Window>;
|
||||
}
|
||||
|
||||
interface WindowWithDocumentPiP {
|
||||
documentPictureInPicture: DocumentPictureInPicture;
|
||||
}
|
||||
|
||||
export interface UseDocumentPiPOptions extends ConfigurableWindow {
|
||||
/**
|
||||
* Called when `open()` rejects (e.g. not triggered by a user gesture) instead
|
||||
* of throwing. The same value is also stored in the returned `error` ref.
|
||||
*
|
||||
* @default noop
|
||||
*/
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export interface UseDocumentPiPReturn {
|
||||
/**
|
||||
* Whether the [Document Picture-in-Picture API](https://developer.mozilla.org/en-US/docs/Web/API/DocumentPictureInPicture) is supported
|
||||
*/
|
||||
isSupported: ComputedRef<boolean>;
|
||||
|
||||
/**
|
||||
* The active Picture-in-Picture `Window`, or `null` when none is open
|
||||
*/
|
||||
pipWindow: ShallowRef<Window | null>;
|
||||
|
||||
/**
|
||||
* Whether a Picture-in-Picture window is currently open
|
||||
*/
|
||||
isOpen: ComputedRef<boolean>;
|
||||
|
||||
/**
|
||||
* The last error thrown by `open()`, or `null`
|
||||
*/
|
||||
error: ShallowRef<unknown | null>;
|
||||
|
||||
/**
|
||||
* Open a Picture-in-Picture window. Must be called from a user gesture.
|
||||
* Resolves with the new `Window`, or `undefined` when unsupported.
|
||||
*/
|
||||
open: (pipOptions?: DocumentPictureInPictureOptions) => Promise<Window | undefined>;
|
||||
|
||||
/**
|
||||
* Close the active Picture-in-Picture window, if any
|
||||
*/
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useDocumentPiP
|
||||
* @category Browser
|
||||
* @description Reactive wrapper around the [Document Picture-in-Picture API](https://developer.mozilla.org/en-US/docs/Web/API/DocumentPictureInPicture) for rendering arbitrary DOM in an always-on-top window.
|
||||
*
|
||||
* @param {UseDocumentPiPOptions} [options={}] Options
|
||||
* @param {Function} [options.onError=noop] Error callback invoked instead of throwing
|
||||
* @param {Window} [options.window=defaultWindow] Custom `window` instance
|
||||
* @returns {UseDocumentPiPReturn} `isSupported`, `pipWindow`, `isOpen`, `error`, `open()`, and `close()`
|
||||
*
|
||||
* @example
|
||||
* const { isSupported, pipWindow, open } = useDocumentPiP();
|
||||
* async function popOut(content: HTMLElement) {
|
||||
* const win = await open({ width: 320, height: 240 });
|
||||
* win?.document.body.append(content);
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
* // Move a player into the PiP window and track open state
|
||||
* const { isOpen, pipWindow, open, close } = useDocumentPiP();
|
||||
* watchEffect(() => {
|
||||
* if (pipWindow.value)
|
||||
* pipWindow.value.document.body.append(playerEl);
|
||||
* });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useDocumentPiP(options: UseDocumentPiPOptions = {}): UseDocumentPiPReturn {
|
||||
const {
|
||||
window = defaultWindow,
|
||||
onError = noop,
|
||||
} = options;
|
||||
|
||||
const isSupported = useSupported(() => !!window && 'documentPictureInPicture' in window);
|
||||
const pipWindow = shallowRef<Window | null>(null);
|
||||
const error = shallowRef<unknown | null>(null);
|
||||
|
||||
const isOpen = computed<boolean>(() => pipWindow.value !== null);
|
||||
|
||||
function handleClose(): void {
|
||||
pipWindow.value = null;
|
||||
}
|
||||
|
||||
async function open(pipOptions?: DocumentPictureInPictureOptions): Promise<Window | undefined> {
|
||||
if (!isSupported.value || !window)
|
||||
return undefined;
|
||||
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const controller = (window as unknown as WindowWithDocumentPiP).documentPictureInPicture;
|
||||
const pip = await controller.requestWindow(pipOptions);
|
||||
|
||||
// The PiP window closing (user or programmatic) clears our reference.
|
||||
pip.addEventListener('pagehide', handleClose, { once: true });
|
||||
pipWindow.value = pip;
|
||||
|
||||
return pip;
|
||||
}
|
||||
catch (err) {
|
||||
error.value = err;
|
||||
onError(err);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
pipWindow.value?.close();
|
||||
pipWindow.value = null;
|
||||
}
|
||||
|
||||
tryOnScopeDispose(close);
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
pipWindow,
|
||||
isOpen,
|
||||
error,
|
||||
open,
|
||||
close,
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { shallowRef } from 'vue';
|
||||
import type { ComputedRef, ShallowRef } from 'vue';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { useSupported } from '@/composables/browser/useSupported';
|
||||
import { useSupported } from '@/composables/utilities/useSupported';
|
||||
|
||||
export interface EyeDropperOpenOptions {
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick, ref } from 'vue';
|
||||
import type { FileSystemFileHandle, UseFileSystemAccessReturn } from '.';
|
||||
import { useFileSystemAccess } from '.';
|
||||
|
||||
interface WritableSpy {
|
||||
write: ReturnType<typeof vi.fn>;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function makeFile(contents = 'hello world', name = 'demo.txt', type = 'text/plain'): File {
|
||||
const file = {
|
||||
name,
|
||||
type,
|
||||
size: contents.length,
|
||||
lastModified: 1234,
|
||||
text: vi.fn(async () => contents),
|
||||
arrayBuffer: vi.fn(async () => new ArrayBuffer(contents.length)),
|
||||
};
|
||||
return file as unknown as File;
|
||||
}
|
||||
|
||||
function makeHandle(file: File): { handle: FileSystemFileHandle; writable: WritableSpy } {
|
||||
const writable: WritableSpy = {
|
||||
write: vi.fn(async () => {}),
|
||||
close: vi.fn(async () => {}),
|
||||
};
|
||||
const handle = {
|
||||
getFile: vi.fn(async () => file),
|
||||
createWritable: vi.fn(async () => writable),
|
||||
} as unknown as FileSystemFileHandle;
|
||||
return { handle, writable };
|
||||
}
|
||||
|
||||
function stubWindow(handle?: FileSystemFileHandle) {
|
||||
const showOpenFilePicker = vi.fn(async () => (handle ? [handle] : []));
|
||||
const showSaveFilePicker = vi.fn(async () => handle);
|
||||
const window = {
|
||||
showOpenFilePicker,
|
||||
showSaveFilePicker,
|
||||
} as unknown as Window;
|
||||
return { window, showOpenFilePicker, showSaveFilePicker };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe(useFileSystemAccess, () => {
|
||||
it('reports support when the picker APIs exist', () => {
|
||||
const { window } = stubWindow();
|
||||
const scope = effectScope();
|
||||
let fsa: UseFileSystemAccessReturn;
|
||||
scope.run(() => {
|
||||
fsa = useFileSystemAccess({ window });
|
||||
});
|
||||
expect(fsa!.isSupported.value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('is not supported without the picker APIs', () => {
|
||||
const window = {} as unknown as Window;
|
||||
const scope = effectScope();
|
||||
let fsa: UseFileSystemAccessReturn;
|
||||
scope.run(() => {
|
||||
fsa = useFileSystemAccess({ window });
|
||||
});
|
||||
expect(fsa!.isSupported.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('is not supported and is a no-op under SSR (no window)', async () => {
|
||||
const scope = effectScope();
|
||||
let fsa: UseFileSystemAccessReturn;
|
||||
scope.run(() => {
|
||||
fsa = useFileSystemAccess({ window: undefined });
|
||||
});
|
||||
expect(fsa!.isSupported.value).toBeFalsy();
|
||||
await expect(fsa!.open()).resolves.toBeUndefined();
|
||||
await expect(fsa!.save()).resolves.toBeUndefined();
|
||||
expect(fsa!.fileName.value).toBe('');
|
||||
expect(fsa!.fileSize.value).toBe(0);
|
||||
expect(fsa!.data.value).toBeUndefined();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('opens a file and reads it as text by default', async () => {
|
||||
const file = makeFile('content');
|
||||
const { handle } = makeHandle(file);
|
||||
const { window, showOpenFilePicker } = stubWindow(handle);
|
||||
const scope = effectScope();
|
||||
let fsa: UseFileSystemAccessReturn<string>;
|
||||
scope.run(() => {
|
||||
fsa = useFileSystemAccess({ window, dataType: 'Text' });
|
||||
});
|
||||
|
||||
await fsa!.open();
|
||||
expect(showOpenFilePicker).toHaveBeenCalledOnce();
|
||||
expect(fsa!.data.value).toBe('content');
|
||||
expect(fsa!.file.value).toBe(file);
|
||||
expect(fsa!.fileName.value).toBe('demo.txt');
|
||||
expect(fsa!.fileMIME.value).toBe('text/plain');
|
||||
expect(fsa!.fileSize.value).toBe(7);
|
||||
expect(fsa!.fileLastModified.value).toBe(1234);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reads as ArrayBuffer when dataType is ArrayBuffer', async () => {
|
||||
const file = makeFile('abc');
|
||||
const { handle } = makeHandle(file);
|
||||
const { window } = stubWindow(handle);
|
||||
const scope = effectScope();
|
||||
let fsa: UseFileSystemAccessReturn<ArrayBuffer>;
|
||||
scope.run(() => {
|
||||
fsa = useFileSystemAccess({ window, dataType: 'ArrayBuffer' });
|
||||
});
|
||||
|
||||
await fsa!.open();
|
||||
expect(fsa!.data.value).toBeInstanceOf(ArrayBuffer);
|
||||
expect(file.arrayBuffer).toHaveBeenCalled();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('exposes the File itself when dataType is Blob', async () => {
|
||||
const file = makeFile('abc');
|
||||
const { handle } = makeHandle(file);
|
||||
const { window } = stubWindow(handle);
|
||||
const scope = effectScope();
|
||||
let fsa: UseFileSystemAccessReturn<Blob>;
|
||||
scope.run(() => {
|
||||
fsa = useFileSystemAccess({ window, dataType: 'Blob' });
|
||||
});
|
||||
|
||||
await fsa!.open();
|
||||
expect(fsa!.data.value).toBe(file);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('passes types and excludeAcceptAllOption to the open picker', async () => {
|
||||
const file = makeFile();
|
||||
const { handle } = makeHandle(file);
|
||||
const { window, showOpenFilePicker } = stubWindow(handle);
|
||||
const types = [{ description: 'text', accept: { 'text/plain': ['.txt'] } }];
|
||||
const scope = effectScope();
|
||||
let fsa: UseFileSystemAccessReturn;
|
||||
scope.run(() => {
|
||||
fsa = useFileSystemAccess({ window, types, excludeAcceptAllOption: true });
|
||||
});
|
||||
|
||||
await fsa!.open();
|
||||
expect(showOpenFilePicker).toHaveBeenCalledWith({ types, excludeAcceptAllOption: true });
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('lets per-call open options override the defaults', async () => {
|
||||
const file = makeFile();
|
||||
const { handle } = makeHandle(file);
|
||||
const { window, showOpenFilePicker } = stubWindow(handle);
|
||||
const scope = effectScope();
|
||||
let fsa: UseFileSystemAccessReturn;
|
||||
scope.run(() => {
|
||||
fsa = useFileSystemAccess({ window, excludeAcceptAllOption: false });
|
||||
});
|
||||
|
||||
await fsa!.open({ excludeAcceptAllOption: true });
|
||||
expect(showOpenFilePicker).toHaveBeenCalledWith({ types: undefined, excludeAcceptAllOption: true });
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('creates a new empty handle and clears prior data', async () => {
|
||||
const file = makeFile('');
|
||||
const { handle } = makeHandle(file);
|
||||
const { window, showSaveFilePicker } = stubWindow(handle);
|
||||
const scope = effectScope();
|
||||
let fsa: UseFileSystemAccessReturn;
|
||||
scope.run(() => {
|
||||
fsa = useFileSystemAccess({ window });
|
||||
});
|
||||
|
||||
await fsa!.create({ suggestedName: 'new.txt' });
|
||||
expect(showSaveFilePicker).toHaveBeenCalledWith({ types: undefined, excludeAcceptAllOption: undefined, suggestedName: 'new.txt' });
|
||||
expect(fsa!.file.value).toBe(file);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('save writes current data to the existing handle', async () => {
|
||||
const file = makeFile('original');
|
||||
const { handle, writable } = makeHandle(file);
|
||||
const { window } = stubWindow(handle);
|
||||
const scope = effectScope();
|
||||
let fsa: UseFileSystemAccessReturn<string>;
|
||||
scope.run(() => {
|
||||
fsa = useFileSystemAccess({ window, dataType: 'Text' });
|
||||
});
|
||||
|
||||
await fsa!.open();
|
||||
fsa!.data.value = 'edited';
|
||||
await fsa!.save();
|
||||
|
||||
expect(writable.write).toHaveBeenCalledWith('edited');
|
||||
expect(writable.close).toHaveBeenCalledOnce();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('save falls back to saveAs when there is no handle', async () => {
|
||||
const file = makeFile('');
|
||||
const { handle, writable } = makeHandle(file);
|
||||
const { window, showSaveFilePicker, showOpenFilePicker } = stubWindow(handle);
|
||||
const scope = effectScope();
|
||||
let fsa: UseFileSystemAccessReturn<string>;
|
||||
scope.run(() => {
|
||||
fsa = useFileSystemAccess({ window, dataType: 'Text' });
|
||||
});
|
||||
|
||||
fsa!.data.value = 'fresh';
|
||||
await fsa!.save();
|
||||
|
||||
expect(showOpenFilePicker).not.toHaveBeenCalled();
|
||||
expect(showSaveFilePicker).toHaveBeenCalledOnce();
|
||||
expect(writable.write).toHaveBeenCalledWith('fresh');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('saveAs requests a new handle and writes data', async () => {
|
||||
const file = makeFile('');
|
||||
const { handle, writable } = makeHandle(file);
|
||||
const { window, showSaveFilePicker } = stubWindow(handle);
|
||||
const scope = effectScope();
|
||||
let fsa: UseFileSystemAccessReturn<string>;
|
||||
scope.run(() => {
|
||||
fsa = useFileSystemAccess({ window, dataType: 'Text' });
|
||||
});
|
||||
|
||||
fsa!.data.value = 'payload';
|
||||
await fsa!.saveAs({ suggestedName: 'out.txt' });
|
||||
|
||||
expect(showSaveFilePicker).toHaveBeenCalledWith({ types: undefined, excludeAcceptAllOption: undefined, suggestedName: 'out.txt' });
|
||||
expect(writable.write).toHaveBeenCalledWith('payload');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not write when there is no data', async () => {
|
||||
const file = makeFile('');
|
||||
const { handle, writable } = makeHandle(file);
|
||||
const { window } = stubWindow(handle);
|
||||
const scope = effectScope();
|
||||
let fsa: UseFileSystemAccessReturn<string>;
|
||||
scope.run(() => {
|
||||
fsa = useFileSystemAccess({ window, dataType: 'Text' });
|
||||
});
|
||||
|
||||
await fsa!.saveAs();
|
||||
expect(writable.write).not.toHaveBeenCalled();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('updateData re-reads the current file', async () => {
|
||||
const file = makeFile('v1');
|
||||
const { handle } = makeHandle(file);
|
||||
const { window } = stubWindow(handle);
|
||||
const scope = effectScope();
|
||||
let fsa: UseFileSystemAccessReturn<string>;
|
||||
scope.run(() => {
|
||||
fsa = useFileSystemAccess({ window, dataType: 'Text' });
|
||||
});
|
||||
|
||||
await fsa!.open();
|
||||
expect(fsa!.data.value).toBe('v1');
|
||||
|
||||
(file.text as ReturnType<typeof vi.fn>).mockResolvedValueOnce('v2');
|
||||
await fsa!.updateData();
|
||||
expect(fsa!.data.value).toBe('v2');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('re-reads data when a reactive dataType changes', async () => {
|
||||
const file = makeFile('reactive');
|
||||
const { handle } = makeHandle(file);
|
||||
const { window } = stubWindow(handle);
|
||||
const dataType = ref<'Text' | 'Blob'>('Text');
|
||||
const scope = effectScope();
|
||||
let fsa: UseFileSystemAccessReturn;
|
||||
scope.run(() => {
|
||||
fsa = useFileSystemAccess({ window, dataType });
|
||||
});
|
||||
|
||||
await fsa!.open();
|
||||
expect(fsa!.data.value).toBe('reactive');
|
||||
|
||||
dataType.value = 'Blob';
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
expect(fsa!.data.value).toBe(file);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('routes picker errors to onError instead of throwing', async () => {
|
||||
const abort = new DOMException('cancelled', 'AbortError');
|
||||
const window = {
|
||||
showOpenFilePicker: vi.fn(async () => { throw abort; }),
|
||||
showSaveFilePicker: vi.fn(async () => { throw abort; }),
|
||||
} as unknown as Window;
|
||||
const onError = vi.fn();
|
||||
const scope = effectScope();
|
||||
let fsa: UseFileSystemAccessReturn;
|
||||
scope.run(() => {
|
||||
fsa = useFileSystemAccess({ window, onError });
|
||||
});
|
||||
|
||||
await expect(fsa!.open()).resolves.toBeUndefined();
|
||||
expect(onError).toHaveBeenCalledWith(abort);
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,332 @@
|
||||
import { computed, shallowRef, toValue, watch } from 'vue';
|
||||
import type { ComputedRef, MaybeRefOrGetter, ShallowRef } from 'vue';
|
||||
import { noop } from '@robonen/stdlib';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { useSupported } from '@/composables/utilities/useSupported';
|
||||
|
||||
/**
|
||||
* `window.showOpenFilePicker` parameters.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/Window/showOpenFilePicker#parameters
|
||||
*/
|
||||
export interface FileSystemAccessShowOpenFileOptions {
|
||||
multiple?: boolean;
|
||||
types?: Array<{
|
||||
description?: string;
|
||||
accept: Record<string, string[]>;
|
||||
}>;
|
||||
excludeAcceptAllOption?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* `window.showSaveFilePicker` parameters.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/Window/showSaveFilePicker#parameters
|
||||
*/
|
||||
export interface FileSystemAccessShowSaveFileOptions {
|
||||
suggestedName?: string;
|
||||
types?: Array<{
|
||||
description?: string;
|
||||
accept: Record<string, string[]>;
|
||||
}>;
|
||||
excludeAcceptAllOption?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/FileSystemWritableFileStream/write
|
||||
*/
|
||||
export interface FileSystemWritableFileStreamWrite {
|
||||
(data: string | BufferSource | Blob): Promise<void>;
|
||||
(options: { type: 'write'; position: number; data: string | BufferSource | Blob }): Promise<void>;
|
||||
(options: { type: 'seek'; position: number }): Promise<void>;
|
||||
(options: { type: 'truncate'; size: number }): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/FileSystemWritableFileStream
|
||||
*/
|
||||
export interface FileSystemWritableFileStream extends WritableStream {
|
||||
write: FileSystemWritableFileStreamWrite;
|
||||
seek: (position: number) => Promise<void>;
|
||||
truncate: (size: number) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/FileSystemFileHandle
|
||||
*/
|
||||
export interface FileSystemFileHandle {
|
||||
getFile: () => Promise<File>;
|
||||
createWritable: () => Promise<FileSystemWritableFileStream>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A `window` augmented with the File System Access API entry points.
|
||||
*/
|
||||
export type FileSystemAccessWindow
|
||||
= Window & {
|
||||
showSaveFilePicker: (options: FileSystemAccessShowSaveFileOptions) => Promise<FileSystemFileHandle>;
|
||||
showOpenFilePicker: (options: FileSystemAccessShowOpenFileOptions) => Promise<FileSystemFileHandle[]>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The supported file data types.
|
||||
*/
|
||||
export type UseFileSystemAccessDataType = 'Text' | 'ArrayBuffer' | 'Blob';
|
||||
|
||||
/**
|
||||
* Picker options shared between open/create/save operations.
|
||||
*/
|
||||
export type UseFileSystemAccessCommonOptions
|
||||
= Pick<FileSystemAccessShowOpenFileOptions, 'types' | 'excludeAcceptAllOption'>;
|
||||
|
||||
/**
|
||||
* Picker options accepted by save-style operations.
|
||||
*/
|
||||
export type UseFileSystemAccessShowSaveFileOptions
|
||||
= Pick<FileSystemAccessShowSaveFileOptions, 'suggestedName'> & UseFileSystemAccessCommonOptions;
|
||||
|
||||
export type UseFileSystemAccessOptions
|
||||
= ConfigurableWindow & UseFileSystemAccessCommonOptions & {
|
||||
/**
|
||||
* How the file contents are read into `data`.
|
||||
*
|
||||
* @default 'Text'
|
||||
*/
|
||||
dataType?: MaybeRefOrGetter<UseFileSystemAccessDataType>;
|
||||
|
||||
/**
|
||||
* Called when a picker or file operation fails.
|
||||
*
|
||||
* User-cancelled pickers reject with an `AbortError`; route them here to
|
||||
* keep them out of the global unhandled-rejection channel.
|
||||
*
|
||||
* @default noop
|
||||
*/
|
||||
onError?: (error: unknown) => void;
|
||||
};
|
||||
|
||||
export interface UseFileSystemAccessReturn<T = string | ArrayBuffer | Blob> {
|
||||
/**
|
||||
* Whether the File System Access API is available.
|
||||
*/
|
||||
isSupported: ComputedRef<boolean>;
|
||||
|
||||
/**
|
||||
* The current file contents, read according to `dataType`.
|
||||
*/
|
||||
data: ShallowRef<T | undefined>;
|
||||
|
||||
/**
|
||||
* The currently bound `File`, or `undefined` when no file is open.
|
||||
*/
|
||||
file: ShallowRef<File | undefined>;
|
||||
|
||||
/**
|
||||
* The current file name (empty string when no file is open).
|
||||
*/
|
||||
fileName: ComputedRef<string>;
|
||||
|
||||
/**
|
||||
* The current file MIME type (empty string when no file is open).
|
||||
*/
|
||||
fileMIME: ComputedRef<string>;
|
||||
|
||||
/**
|
||||
* The current file size in bytes (`0` when no file is open).
|
||||
*/
|
||||
fileSize: ComputedRef<number>;
|
||||
|
||||
/**
|
||||
* The current file's last-modified timestamp (`0` when no file is open).
|
||||
*/
|
||||
fileLastModified: ComputedRef<number>;
|
||||
|
||||
/**
|
||||
* Show the open-file picker and load the chosen file.
|
||||
*/
|
||||
open: (options?: UseFileSystemAccessCommonOptions) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Show the save-file picker to create a new, empty file handle.
|
||||
*/
|
||||
create: (options?: UseFileSystemAccessShowSaveFileOptions) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Write `data` back to the current handle (falls back to `saveAs` when none).
|
||||
*/
|
||||
save: (options?: UseFileSystemAccessShowSaveFileOptions) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Show the save-file picker, then write `data` to the chosen handle.
|
||||
*/
|
||||
saveAs: (options?: UseFileSystemAccessShowSaveFileOptions) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Re-read `data` (and metadata) from the current handle.
|
||||
*/
|
||||
updateData: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useFileSystemAccess
|
||||
* @category Browser
|
||||
* @description Create, read, and write local files via the File System Access API.
|
||||
*
|
||||
* @param {UseFileSystemAccessOptions} [options={}] Options including `dataType`, `types`, `excludeAcceptAllOption`, and `onError`
|
||||
* @returns {UseFileSystemAccessReturn} `isSupported`, `data`, `file`, `fileName`, `fileMIME`, `fileSize`, `fileLastModified`, `open`, `create`, `save`, `saveAs`, `updateData`
|
||||
*
|
||||
* @example
|
||||
* const { isSupported, data, open, save } = useFileSystemAccess({ dataType: 'Text' });
|
||||
* await open();
|
||||
* data.value += '\nappended';
|
||||
* await save();
|
||||
*
|
||||
* @example
|
||||
* // Read raw bytes
|
||||
* const { data } = useFileSystemAccess({ dataType: 'ArrayBuffer' });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useFileSystemAccess(): UseFileSystemAccessReturn<string | ArrayBuffer | Blob>;
|
||||
export function useFileSystemAccess(options: UseFileSystemAccessOptions & { dataType: 'Text' }): UseFileSystemAccessReturn<string>;
|
||||
export function useFileSystemAccess(options: UseFileSystemAccessOptions & { dataType: 'ArrayBuffer' }): UseFileSystemAccessReturn<ArrayBuffer>;
|
||||
export function useFileSystemAccess(options: UseFileSystemAccessOptions & { dataType: 'Blob' }): UseFileSystemAccessReturn<Blob>;
|
||||
export function useFileSystemAccess(options: UseFileSystemAccessOptions): UseFileSystemAccessReturn<string | ArrayBuffer | Blob>;
|
||||
export function useFileSystemAccess(
|
||||
options: UseFileSystemAccessOptions = {},
|
||||
): UseFileSystemAccessReturn<string | ArrayBuffer | Blob> {
|
||||
const {
|
||||
window: win = defaultWindow,
|
||||
dataType = 'Text',
|
||||
types,
|
||||
excludeAcceptAllOption,
|
||||
onError = noop,
|
||||
} = options;
|
||||
|
||||
const fsWindow = win as FileSystemAccessWindow | undefined;
|
||||
const isSupported = useSupported(() => fsWindow && 'showSaveFilePicker' in fsWindow && 'showOpenFilePicker' in fsWindow);
|
||||
|
||||
const fileHandle = shallowRef<FileSystemFileHandle>();
|
||||
const data = shallowRef<string | ArrayBuffer | Blob>();
|
||||
const file = shallowRef<File>();
|
||||
|
||||
const fileName = computed(() => file.value?.name ?? '');
|
||||
const fileMIME = computed(() => file.value?.type ?? '');
|
||||
const fileSize = computed(() => file.value?.size ?? 0);
|
||||
const fileLastModified = computed(() => file.value?.lastModified ?? 0);
|
||||
|
||||
// Resolve the picker defaults once per call rather than spreading the full
|
||||
// options bag (which carries `window`/`onError`) into the native picker.
|
||||
function pickerDefaults(): UseFileSystemAccessCommonOptions {
|
||||
return { types, excludeAcceptAllOption };
|
||||
}
|
||||
|
||||
async function updateFile(): Promise<void> {
|
||||
file.value = await fileHandle.value?.getFile();
|
||||
}
|
||||
|
||||
async function updateData(): Promise<void> {
|
||||
await updateFile();
|
||||
|
||||
const type = toValue(dataType);
|
||||
|
||||
if (type === 'Text')
|
||||
data.value = await file.value?.text();
|
||||
else if (type === 'ArrayBuffer')
|
||||
data.value = await file.value?.arrayBuffer();
|
||||
else if (type === 'Blob')
|
||||
data.value = file.value;
|
||||
}
|
||||
|
||||
async function writeData(): Promise<void> {
|
||||
if (!fileHandle.value || data.value === undefined || data.value === null)
|
||||
return;
|
||||
|
||||
const stream = await fileHandle.value.createWritable();
|
||||
await stream.write(data.value);
|
||||
await stream.close();
|
||||
}
|
||||
|
||||
async function open(_options: UseFileSystemAccessCommonOptions = {}): Promise<void> {
|
||||
if (!isSupported.value || !fsWindow)
|
||||
return;
|
||||
|
||||
try {
|
||||
const [handle] = await fsWindow.showOpenFilePicker({ ...pickerDefaults(), ..._options });
|
||||
if (!handle)
|
||||
return;
|
||||
fileHandle.value = handle;
|
||||
await updateData();
|
||||
}
|
||||
catch (error) {
|
||||
onError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function create(_options: UseFileSystemAccessShowSaveFileOptions = {}): Promise<void> {
|
||||
if (!isSupported.value || !fsWindow)
|
||||
return;
|
||||
|
||||
try {
|
||||
fileHandle.value = await fsWindow.showSaveFilePicker({ ...pickerDefaults(), ..._options });
|
||||
data.value = undefined;
|
||||
await updateData();
|
||||
}
|
||||
catch (error) {
|
||||
onError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAs(_options: UseFileSystemAccessShowSaveFileOptions = {}): Promise<void> {
|
||||
if (!isSupported.value || !fsWindow)
|
||||
return;
|
||||
|
||||
try {
|
||||
fileHandle.value = await fsWindow.showSaveFilePicker({ ...pickerDefaults(), ..._options });
|
||||
await writeData();
|
||||
await updateFile();
|
||||
}
|
||||
catch (error) {
|
||||
onError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function save(_options: UseFileSystemAccessShowSaveFileOptions = {}): Promise<void> {
|
||||
if (!isSupported.value)
|
||||
return;
|
||||
|
||||
if (!fileHandle.value)
|
||||
return saveAs(_options);
|
||||
|
||||
try {
|
||||
await writeData();
|
||||
await updateFile();
|
||||
}
|
||||
catch (error) {
|
||||
onError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-read with the new strategy whenever `dataType` changes; skip the redundant
|
||||
// initial run since nothing is open yet.
|
||||
watch(() => toValue(dataType), () => {
|
||||
if (fileHandle.value)
|
||||
void updateData();
|
||||
});
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
data,
|
||||
file,
|
||||
fileName,
|
||||
fileMIME,
|
||||
fileSize,
|
||||
fileLastModified,
|
||||
open,
|
||||
create,
|
||||
save,
|
||||
saveAs,
|
||||
updateData,
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { defaultDocument } from '@/types';
|
||||
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
||||
import { unrefElement } from '@/composables/component/unrefElement';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
import { useSupported } from '@/composables/browser/useSupported';
|
||||
import { useSupported } from '@/composables/utilities/useSupported';
|
||||
import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { effectScope, nextTick, ref } from 'vue';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useImage } from '.';
|
||||
|
||||
interface FakeImage {
|
||||
src: string;
|
||||
srcset: string;
|
||||
sizes: string;
|
||||
alt: string;
|
||||
className: string;
|
||||
loading: string;
|
||||
crossOrigin: string | null;
|
||||
referrerPolicy: string;
|
||||
width: number;
|
||||
height: number;
|
||||
decoding: string;
|
||||
fetchPriority: string;
|
||||
isMap: boolean;
|
||||
useMap: string;
|
||||
onload: (() => void) | null;
|
||||
onerror: ((err: unknown) => void) | null;
|
||||
}
|
||||
|
||||
let lastImage: FakeImage | undefined;
|
||||
// Decides whether the next constructed image "loads" or "errors".
|
||||
let shouldFail = false;
|
||||
|
||||
function createImage(): FakeImage {
|
||||
const img: FakeImage = {
|
||||
src: '',
|
||||
srcset: '',
|
||||
sizes: '',
|
||||
alt: '',
|
||||
className: '',
|
||||
loading: '',
|
||||
crossOrigin: null,
|
||||
referrerPolicy: '',
|
||||
width: 0,
|
||||
height: 0,
|
||||
decoding: '',
|
||||
fetchPriority: '',
|
||||
isMap: false,
|
||||
useMap: '',
|
||||
onload: null,
|
||||
onerror: null,
|
||||
};
|
||||
|
||||
lastImage = img;
|
||||
|
||||
// Mimic the browser firing load/error asynchronously after src is set.
|
||||
queueMicrotask(() => {
|
||||
if (shouldFail)
|
||||
img.onerror?.(new Error('load-error'));
|
||||
else
|
||||
img.onload?.();
|
||||
});
|
||||
|
||||
return img;
|
||||
}
|
||||
|
||||
function createFakeWindow(): Window {
|
||||
const Image = function Image(): FakeImage {
|
||||
return createImage();
|
||||
} as unknown as new () => HTMLImageElement;
|
||||
|
||||
return { Image } as unknown as Window;
|
||||
}
|
||||
|
||||
describe(useImage, () => {
|
||||
let window: Window;
|
||||
|
||||
beforeEach(() => {
|
||||
lastImage = undefined;
|
||||
shouldFail = false;
|
||||
window = createFakeWindow();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('loads an image and exposes the element as state', async () => {
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useImage>;
|
||||
scope.run(() => {
|
||||
result = useImage({ src: '/cat.png' }, { window });
|
||||
});
|
||||
|
||||
expect(result.isLoading.value).toBeTruthy();
|
||||
expect(result.isReady.value).toBeFalsy();
|
||||
expect(result.error.value).toBe(null);
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(result.isLoading.value).toBeFalsy();
|
||||
expect(result.isReady.value).toBeTruthy();
|
||||
expect(result.error.value).toBe(null);
|
||||
expect(result.state.value).toBe(lastImage);
|
||||
expect(lastImage?.src).toBe('/cat.png');
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('applies the provided image attributes', async () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
useImage(
|
||||
{
|
||||
src: '/cat.png',
|
||||
srcset: '/cat-2x.png 2x',
|
||||
sizes: '100vw',
|
||||
alt: 'a cat',
|
||||
class: 'rounded',
|
||||
loading: 'lazy',
|
||||
crossorigin: 'anonymous',
|
||||
referrerPolicy: 'no-referrer',
|
||||
width: 320,
|
||||
height: 240,
|
||||
decoding: 'async',
|
||||
fetchPriority: 'high',
|
||||
ismap: true,
|
||||
usemap: '#map',
|
||||
},
|
||||
{ window },
|
||||
);
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(lastImage).toBeDefined();
|
||||
expect(lastImage!.src).toBe('/cat.png');
|
||||
expect(lastImage!.srcset).toBe('/cat-2x.png 2x');
|
||||
expect(lastImage!.sizes).toBe('100vw');
|
||||
expect(lastImage!.alt).toBe('a cat');
|
||||
expect(lastImage!.className).toBe('rounded');
|
||||
expect(lastImage!.loading).toBe('lazy');
|
||||
expect(lastImage!.crossOrigin).toBe('anonymous');
|
||||
expect(lastImage!.referrerPolicy).toBe('no-referrer');
|
||||
expect(lastImage!.width).toBe(320);
|
||||
expect(lastImage!.height).toBe(240);
|
||||
expect(lastImage!.decoding).toBe('async');
|
||||
expect(lastImage!.fetchPriority).toBe('high');
|
||||
expect(lastImage!.isMap).toBeTruthy();
|
||||
expect(lastImage!.useMap).toBe('#map');
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('captures load errors', async () => {
|
||||
shouldFail = true;
|
||||
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useImage>;
|
||||
scope.run(() => {
|
||||
result = useImage({ src: '/missing.png' }, { window });
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(result.isLoading.value).toBeFalsy();
|
||||
expect(result.isReady.value).toBeFalsy();
|
||||
expect(result.error.value).toBeInstanceOf(Error);
|
||||
expect(result.state.value).toBe(undefined);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reloads when a reactive source changes', async () => {
|
||||
const src = ref('/a.png');
|
||||
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useImage>;
|
||||
scope.run(() => {
|
||||
result = useImage(() => ({ src: src.value }), { window });
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(lastImage?.src).toBe('/a.png');
|
||||
expect(result.isReady.value).toBeTruthy();
|
||||
|
||||
src.value = '/b.png';
|
||||
await nextTick();
|
||||
|
||||
// resetOnExecute clears state and flips loading back on
|
||||
expect(result.isLoading.value).toBeTruthy();
|
||||
expect(result.state.value).toBe(undefined);
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(lastImage?.src).toBe('/b.png');
|
||||
expect(result.isReady.value).toBeTruthy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not set up a watcher for a plain options object', async () => {
|
||||
const watchSpy = vi.fn();
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
const result = useImage({ src: '/static.png' }, { window });
|
||||
// execute is the only thing a reload would call; spy after initial run
|
||||
result.execute = watchSpy;
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(watchSpy).not.toHaveBeenCalled();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not call execute immediately when immediate is false', async () => {
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useImage>;
|
||||
scope.run(() => {
|
||||
result = useImage({ src: '/cat.png' }, { window, immediate: false });
|
||||
});
|
||||
|
||||
expect(result.isLoading.value).toBeFalsy();
|
||||
expect(lastImage).toBeUndefined();
|
||||
|
||||
await result.execute();
|
||||
expect(lastImage?.src).toBe('/cat.png');
|
||||
expect(result.isReady.value).toBeTruthy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('rejects when no Image constructor is available (SSR path)', async () => {
|
||||
const onError = vi.fn();
|
||||
// A window without an Image constructor stands in for a non-DOM environment.
|
||||
const ssrWindow = {} as unknown as Window;
|
||||
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useImage>;
|
||||
scope.run(() => {
|
||||
result = useImage({ src: '/cat.png' }, { window: ssrWindow, onError });
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(result.isReady.value).toBeFalsy();
|
||||
expect(result.error.value).toBeInstanceOf(Error);
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { isRef, toValue, watch } from 'vue';
|
||||
import type { MaybeRefOrGetter } from 'vue';
|
||||
import { isFunction } from '@robonen/stdlib';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { useAsyncState } from '@/composables/state/useAsyncState';
|
||||
import type { UseAsyncStateOptions, UseAsyncStateReturn } from '@/composables/state/useAsyncState';
|
||||
|
||||
export interface UseImageOptions {
|
||||
/** Address of the resource */
|
||||
src: string;
|
||||
/** Images to use in different situations, e.g. high-resolution displays, small monitors, etc. */
|
||||
srcset?: string;
|
||||
/** Image sizes for different page layouts */
|
||||
sizes?: string;
|
||||
/** Image alternative information */
|
||||
alt?: string;
|
||||
/** Image classes */
|
||||
class?: string;
|
||||
/** Image loading strategy */
|
||||
loading?: HTMLImageElement['loading'];
|
||||
/** Image CORS settings */
|
||||
crossorigin?: string;
|
||||
/** Referrer policy for fetch — https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy */
|
||||
referrerPolicy?: HTMLImageElement['referrerPolicy'];
|
||||
/** Image width */
|
||||
width?: HTMLImageElement['width'];
|
||||
/** Image height */
|
||||
height?: HTMLImageElement['height'];
|
||||
/** Image decoding hint — https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#decoding */
|
||||
decoding?: HTMLImageElement['decoding'];
|
||||
/** Relative priority hint for fetching the image */
|
||||
fetchPriority?: HTMLImageElement['fetchPriority'];
|
||||
/** Whether the image is a server-side image map */
|
||||
ismap?: HTMLImageElement['isMap'];
|
||||
/** Partial URL (starting with #) of an image map associated with the element */
|
||||
usemap?: HTMLImageElement['useMap'];
|
||||
}
|
||||
|
||||
export interface UseImageAsyncStateOptions
|
||||
extends UseAsyncStateOptions<true, HTMLImageElement | undefined>, ConfigurableWindow {}
|
||||
|
||||
export type UseImageReturn = UseAsyncStateReturn<HTMLImageElement | undefined, any[], true>;
|
||||
|
||||
interface LoadImageContext {
|
||||
window?: Window;
|
||||
}
|
||||
|
||||
function loadImage(options: UseImageOptions, ctx: LoadImageContext): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// `Image` is a global constructor on `typeof globalThis`, not the `Window` interface.
|
||||
const ImageCtor = (ctx.window as (Window & typeof globalThis) | undefined)?.Image;
|
||||
|
||||
if (!ImageCtor) {
|
||||
reject(new Error('useImage: no Image constructor available (are you running on the server?)'));
|
||||
return;
|
||||
}
|
||||
|
||||
const img = new ImageCtor();
|
||||
const {
|
||||
src,
|
||||
srcset,
|
||||
sizes,
|
||||
alt,
|
||||
class: className,
|
||||
loading,
|
||||
crossorigin,
|
||||
referrerPolicy,
|
||||
width,
|
||||
height,
|
||||
decoding,
|
||||
fetchPriority,
|
||||
ismap,
|
||||
usemap,
|
||||
} = options;
|
||||
|
||||
if (alt !== undefined) img.alt = alt;
|
||||
if (className !== undefined) img.className = className;
|
||||
if (loading !== undefined) img.loading = loading;
|
||||
if (crossorigin !== undefined) img.crossOrigin = crossorigin;
|
||||
if (referrerPolicy !== undefined) img.referrerPolicy = referrerPolicy;
|
||||
if (width !== undefined) img.width = width;
|
||||
if (height !== undefined) img.height = height;
|
||||
if (decoding !== undefined) img.decoding = decoding;
|
||||
if (fetchPriority !== undefined) img.fetchPriority = fetchPriority;
|
||||
if (ismap !== undefined) img.isMap = ismap;
|
||||
if (usemap !== undefined) img.useMap = usemap;
|
||||
|
||||
// Setting srcset/sizes before src lets the browser pick the right candidate up-front.
|
||||
if (sizes !== undefined) img.sizes = sizes;
|
||||
if (srcset !== undefined) img.srcset = srcset;
|
||||
img.src = src;
|
||||
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useImage
|
||||
* @category Browser
|
||||
* @description Reactively load an image in the browser; await the result to render it or show a fallback.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<UseImageOptions>} options Image attributes (as used on the `<img>` tag); pass a ref/getter to reload reactively
|
||||
* @param {UseImageAsyncStateOptions} [asyncStateOptions={}] `useAsyncState` options (`delay`, `immediate`, `onError`, …) plus a configurable `window`
|
||||
* @returns {UseImageReturn} `useAsyncState`-shaped `{ isLoading, isReady, error, state, execute, … }` for an `HTMLImageElement`
|
||||
*
|
||||
* @example
|
||||
* const { isLoading, error, state: image } = useImage({ src: '/cat.png' });
|
||||
*
|
||||
* @example
|
||||
* // Reactive source: reloads whenever `src` changes
|
||||
* const src = ref('/a.png');
|
||||
* const { state } = useImage(() => ({ src: src.value, alt: 'photo' }));
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useImage(
|
||||
options: MaybeRefOrGetter<UseImageOptions>,
|
||||
asyncStateOptions: UseImageAsyncStateOptions = {},
|
||||
): UseImageReturn {
|
||||
const { window = defaultWindow, ...stateOptions } = asyncStateOptions;
|
||||
|
||||
const state = useAsyncState<HTMLImageElement | undefined>(
|
||||
() => loadImage(toValue(options), { window }),
|
||||
undefined,
|
||||
{
|
||||
resetOnExecute: true,
|
||||
...stateOptions,
|
||||
},
|
||||
);
|
||||
|
||||
// A plain (non-ref, non-getter) options object can never change, so we skip
|
||||
// the watcher entirely — no needless deep traversal on every tick.
|
||||
if (isRef(options) || isFunction(options)) {
|
||||
watch(
|
||||
() => toValue(options),
|
||||
() => state.execute(stateOptions.delay),
|
||||
{ deep: true },
|
||||
);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope } from 'vue';
|
||||
import { useLocalFonts } from '.';
|
||||
import type { FontData, QueryLocalFontsOptions } from '.';
|
||||
|
||||
function makeFont(overrides: Partial<FontData> = {}): FontData {
|
||||
return {
|
||||
postscriptName: 'Arial-BoldMT',
|
||||
fullName: 'Arial Bold',
|
||||
family: 'Arial',
|
||||
style: 'Bold',
|
||||
blob: async () => new Blob(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fake `window` exposing `queryLocalFonts`. Passed through options so it
|
||||
* reaches the import-time-captured `defaultWindow` substitute (see test gotcha).
|
||||
*/
|
||||
function createWindow(fonts: FontData[] = [makeFont()]) {
|
||||
const queryLocalFonts = vi.fn(async (_options?: QueryLocalFontsOptions) => fonts);
|
||||
const win = { queryLocalFonts } as unknown as Window & typeof globalThis;
|
||||
|
||||
return { window: win, queryLocalFonts };
|
||||
}
|
||||
|
||||
describe(useLocalFonts, () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('reports supported when queryLocalFonts exists on window', () => {
|
||||
const scope = effectScope();
|
||||
const { window } = createWindow();
|
||||
|
||||
let result: ReturnType<typeof useLocalFonts>;
|
||||
scope.run(() => {
|
||||
result = useLocalFonts({ window });
|
||||
});
|
||||
|
||||
expect(result!.isSupported.value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reports unsupported when the API is absent', () => {
|
||||
const scope = effectScope();
|
||||
const win = {} as unknown as Window & typeof globalThis;
|
||||
|
||||
let result: ReturnType<typeof useLocalFonts>;
|
||||
scope.run(() => {
|
||||
result = useLocalFonts({ window: win });
|
||||
});
|
||||
|
||||
expect(result!.isSupported.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('is SSR safe when window is undefined', async () => {
|
||||
const scope = effectScope();
|
||||
|
||||
let result: ReturnType<typeof useLocalFonts>;
|
||||
scope.run(() => {
|
||||
result = useLocalFonts({ window: undefined as unknown as Window });
|
||||
});
|
||||
|
||||
expect(result!.isSupported.value).toBeFalsy();
|
||||
await expect(result!.query()).resolves.toBeUndefined();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('defaults fonts to an empty array', () => {
|
||||
const scope = effectScope();
|
||||
const { window } = createWindow();
|
||||
|
||||
let result: ReturnType<typeof useLocalFonts>;
|
||||
scope.run(() => {
|
||||
result = useLocalFonts({ window });
|
||||
});
|
||||
|
||||
expect(result!.fonts.value).toEqual([]);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('populates fonts and returns the list on a successful query', async () => {
|
||||
const scope = effectScope();
|
||||
const fonts = [makeFont(), makeFont({ postscriptName: 'Times', fullName: 'Times New Roman' })];
|
||||
const { window, queryLocalFonts } = createWindow(fonts);
|
||||
|
||||
let result: ReturnType<typeof useLocalFonts>;
|
||||
scope.run(() => {
|
||||
result = useLocalFonts({ window });
|
||||
});
|
||||
|
||||
const returned = await result!.query();
|
||||
|
||||
expect(queryLocalFonts).toHaveBeenCalledTimes(1);
|
||||
expect(returned).toEqual(fonts);
|
||||
expect(result!.fonts.value).toEqual(fonts);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('forwards query options to the native API', async () => {
|
||||
const scope = effectScope();
|
||||
const { window, queryLocalFonts } = createWindow();
|
||||
|
||||
let result: ReturnType<typeof useLocalFonts>;
|
||||
scope.run(() => {
|
||||
result = useLocalFonts({ window });
|
||||
});
|
||||
|
||||
await result!.query({ postscriptNames: ['Arial-BoldMT'] });
|
||||
|
||||
expect(queryLocalFonts).toHaveBeenCalledWith({ postscriptNames: ['Arial-BoldMT'] });
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('stores the error and invokes onError when the query rejects', async () => {
|
||||
const scope = effectScope();
|
||||
const error = new DOMException('denied', 'NotAllowedError');
|
||||
const queryLocalFonts = vi.fn(async () => {
|
||||
throw error;
|
||||
});
|
||||
const win = { queryLocalFonts } as unknown as Window & typeof globalThis;
|
||||
const onError = vi.fn();
|
||||
|
||||
let result: ReturnType<typeof useLocalFonts>;
|
||||
scope.run(() => {
|
||||
result = useLocalFonts({ window: win, onError });
|
||||
});
|
||||
|
||||
await expect(result!.query()).resolves.toBeUndefined();
|
||||
expect(onError).toHaveBeenCalledWith(error);
|
||||
expect(result!.error.value).toBe(error);
|
||||
expect(result!.fonts.value).toEqual([]);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('returns undefined and does not call the API when unsupported', async () => {
|
||||
const scope = effectScope();
|
||||
const win = {} as unknown as Window & typeof globalThis;
|
||||
|
||||
let result: ReturnType<typeof useLocalFonts>;
|
||||
scope.run(() => {
|
||||
result = useLocalFonts({ window: win });
|
||||
});
|
||||
|
||||
await expect(result!.query()).resolves.toBeUndefined();
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { shallowRef } from 'vue';
|
||||
import type { ComputedRef, ShallowRef } from 'vue';
|
||||
import { noop } from '@robonen/stdlib';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { useSupported } from '@/composables/utilities/useSupported';
|
||||
import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted';
|
||||
|
||||
/**
|
||||
* A single font face exposed by the [Local Font Access API](https://developer.mozilla.org/en-US/docs/Web/API/FontData).
|
||||
*/
|
||||
export interface FontData {
|
||||
/**
|
||||
* The PostScript name of the font (e.g. `"Arial-BoldMT"`)
|
||||
*/
|
||||
readonly postscriptName: string;
|
||||
|
||||
/**
|
||||
* The full, human-readable name of the font (e.g. `"Arial Bold"`)
|
||||
*/
|
||||
readonly fullName: string;
|
||||
|
||||
/**
|
||||
* The font family (e.g. `"Arial"`)
|
||||
*/
|
||||
readonly family: string;
|
||||
|
||||
/**
|
||||
* The font style/subfamily (e.g. `"Bold"`)
|
||||
*/
|
||||
readonly style: string;
|
||||
|
||||
/**
|
||||
* Resolve the raw font file as a `Blob` (the full SFNT binary)
|
||||
*/
|
||||
blob: () => Promise<Blob>;
|
||||
}
|
||||
|
||||
export interface QueryLocalFontsOptions {
|
||||
/**
|
||||
* Restrict the results to the fonts whose PostScript names appear in this list.
|
||||
*/
|
||||
postscriptNames?: string[];
|
||||
}
|
||||
|
||||
interface WindowWithLocalFonts {
|
||||
queryLocalFonts: (options?: QueryLocalFontsOptions) => Promise<FontData[]>;
|
||||
}
|
||||
|
||||
export interface UseLocalFontsOptions extends ConfigurableWindow {
|
||||
/**
|
||||
* Query the local fonts immediately on mount. The Local Font Access API
|
||||
* requires the `local-fonts` permission (which may prompt the user), so this
|
||||
* is disabled by default — call `query()` from a user gesture instead.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
immediate?: boolean;
|
||||
|
||||
/**
|
||||
* Called when a query rejects (e.g. the permission is denied) instead of
|
||||
* throwing. The same value is also stored in the returned `error` ref.
|
||||
*
|
||||
* @default noop
|
||||
*/
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export interface UseLocalFontsReturn {
|
||||
/**
|
||||
* Whether the [Local Font Access API](https://developer.mozilla.org/en-US/docs/Web/API/Local_Font_Access_API) is supported
|
||||
*/
|
||||
isSupported: ComputedRef<boolean>;
|
||||
|
||||
/**
|
||||
* The fonts returned by the most recent successful `query()`
|
||||
*/
|
||||
fonts: ShallowRef<FontData[]>;
|
||||
|
||||
/**
|
||||
* The last error thrown by `query()`, or `null`
|
||||
*/
|
||||
error: ShallowRef<unknown | null>;
|
||||
|
||||
/**
|
||||
* Enumerate the locally installed fonts. Resolves with the font list, or
|
||||
* `undefined` when the API is unsupported. Pass `postscriptNames` to filter.
|
||||
*/
|
||||
query: (queryOptions?: QueryLocalFontsOptions) => Promise<FontData[] | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useLocalFonts
|
||||
* @category Browser
|
||||
* @description Reactive wrapper around the [Local Font Access API](https://developer.mozilla.org/en-US/docs/Web/API/Local_Font_Access_API) for enumerating the user's locally installed fonts.
|
||||
*
|
||||
* @param {UseLocalFontsOptions} [options={}] Options
|
||||
* @param {boolean} [options.immediate=false] Query immediately on mount (requires the `local-fonts` permission)
|
||||
* @param {Function} [options.onError=noop] Error callback invoked instead of throwing
|
||||
* @param {Window} [options.window=defaultWindow] Custom `window` instance
|
||||
* @returns {UseLocalFontsReturn} `isSupported`, `fonts`, `error`, and `query()`
|
||||
*
|
||||
* @example
|
||||
* const { isSupported, fonts, query } = useLocalFonts();
|
||||
* // Call from a click handler so the permission prompt is allowed
|
||||
* async function pickFonts() {
|
||||
* await query();
|
||||
* console.log(fonts.value.map(font => font.fullName));
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
* // Query only specific fonts by PostScript name
|
||||
* const { fonts, query } = useLocalFonts();
|
||||
* await query({ postscriptNames: ['Arial-BoldMT'] });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useLocalFonts(options: UseLocalFontsOptions = {}): UseLocalFontsReturn {
|
||||
const {
|
||||
window = defaultWindow,
|
||||
immediate = false,
|
||||
onError = noop,
|
||||
} = options;
|
||||
|
||||
const isSupported = useSupported(() => !!window && 'queryLocalFonts' in window);
|
||||
const fonts = shallowRef<FontData[]>([]);
|
||||
const error = shallowRef<unknown | null>(null);
|
||||
|
||||
async function query(queryOptions?: QueryLocalFontsOptions): Promise<FontData[] | undefined> {
|
||||
if (!isSupported.value || !window)
|
||||
return undefined;
|
||||
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const result = await (window as unknown as WindowWithLocalFonts).queryLocalFonts(queryOptions);
|
||||
fonts.value = result;
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (err) {
|
||||
error.value = err;
|
||||
onError(err);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (immediate)
|
||||
tryOnMounted(() => query());
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
fonts,
|
||||
error,
|
||||
query,
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { ComputedRef, MaybeRefOrGetter } from 'vue';
|
||||
import { isFunction, isNumber } from '@robonen/stdlib';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { useSupported } from '@/composables/browser/useSupported';
|
||||
import { useSupported } from '@/composables/utilities/useSupported';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
|
||||
export interface UseMediaQueryOptions extends ConfigurableWindow {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Ref, ShallowRef } from 'vue';
|
||||
import { isString } from '@robonen/stdlib';
|
||||
import { defaultNavigator } from '@/types';
|
||||
import type { ConfigurableNavigator } from '@/types';
|
||||
import { useSupported } from '@/composables/browser/useSupported';
|
||||
import { useSupported } from '@/composables/utilities/useSupported';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick } from 'vue';
|
||||
import { usePreferredContrast } from '.';
|
||||
|
||||
function stubMatchMedia(matching: string): void {
|
||||
vi.stubGlobal('matchMedia', vi.fn((media: string) => ({
|
||||
matches: media.includes(matching),
|
||||
media,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
})));
|
||||
}
|
||||
|
||||
describe(usePreferredContrast, () => {
|
||||
beforeEach(() => vi.stubGlobal('matchMedia', undefined));
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it.each([
|
||||
['more'],
|
||||
['less'],
|
||||
['custom'],
|
||||
['no-preference'],
|
||||
] as const)('resolves the "%s" contrast preference', async (preference) => {
|
||||
stubMatchMedia(`prefers-contrast: ${preference}`);
|
||||
|
||||
const scope = effectScope();
|
||||
let contrast: ReturnType<typeof usePreferredContrast>;
|
||||
scope.run(() => {
|
||||
contrast = usePreferredContrast();
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(contrast!.value).toBe(preference);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('prioritizes "more" over the other preferences', async () => {
|
||||
// Match everything; the resolution order must win.
|
||||
vi.stubGlobal('matchMedia', vi.fn((media: string) => ({
|
||||
matches: true,
|
||||
media,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
})));
|
||||
|
||||
const scope = effectScope();
|
||||
let contrast: ReturnType<typeof usePreferredContrast>;
|
||||
scope.run(() => {
|
||||
contrast = usePreferredContrast();
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(contrast!.value).toBe('more');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('falls back to "no-preference" when matchMedia is unsupported (SSR)', async () => {
|
||||
// matchMedia is undefined (stubbed in beforeEach).
|
||||
const scope = effectScope();
|
||||
let contrast: ReturnType<typeof usePreferredContrast>;
|
||||
scope.run(() => {
|
||||
contrast = usePreferredContrast();
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(contrast!.value).toBe('no-preference');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('honors a custom ssrContrast fallback when unsupported', async () => {
|
||||
const scope = effectScope();
|
||||
let contrast: ReturnType<typeof usePreferredContrast>;
|
||||
scope.run(() => {
|
||||
contrast = usePreferredContrast({ ssrContrast: 'more' });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(contrast!.value).toBe('more');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('returns "no-preference" with no window provided', async () => {
|
||||
const scope = effectScope();
|
||||
let contrast: ReturnType<typeof usePreferredContrast>;
|
||||
scope.run(() => {
|
||||
contrast = usePreferredContrast({ window: undefined });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(contrast!.value).toBe('no-preference');
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { computed } from 'vue';
|
||||
import type { ComputedRef } from 'vue';
|
||||
import { useMediaQuery } from '@/composables/browser/useMediaQuery';
|
||||
import type { UseMediaQueryOptions } from '@/composables/browser/useMediaQuery';
|
||||
|
||||
export type ContrastType
|
||||
= 'more' | 'less' | 'custom' | 'no-preference';
|
||||
|
||||
export interface UsePreferredContrastOptions extends UseMediaQueryOptions {
|
||||
/**
|
||||
* The contrast preference assumed during SSR (and the first client render),
|
||||
* before `window.matchMedia` is available, to avoid hydration flicker.
|
||||
*
|
||||
* @default 'no-preference'
|
||||
*/
|
||||
ssrContrast?: ContrastType;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name usePreferredContrast
|
||||
* @category Browser
|
||||
* @description Reactive `prefers-contrast` media query, resolving to the user's
|
||||
* preferred contrast level. SSR-safe with an optional SSR fallback value.
|
||||
*
|
||||
* @param {UsePreferredContrastOptions} [options={}] Options (custom `window`, `ssrContrast`)
|
||||
* @returns {ComputedRef<ContrastType>} Readonly ref of the preferred contrast: `'more' | 'less' | 'custom' | 'no-preference'`
|
||||
*
|
||||
* @example
|
||||
* const contrast = usePreferredContrast();
|
||||
*
|
||||
* @example
|
||||
* // Provide an SSR fallback to avoid hydration flicker
|
||||
* const contrast = usePreferredContrast({ ssrContrast: 'more' });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function usePreferredContrast(
|
||||
options: UsePreferredContrastOptions = {},
|
||||
): ComputedRef<ContrastType> {
|
||||
const { ssrContrast = 'no-preference', ...mediaOptions } = options;
|
||||
|
||||
const isMore = useMediaQuery('(prefers-contrast: more)', mediaOptions);
|
||||
const isLess = useMediaQuery('(prefers-contrast: less)', mediaOptions);
|
||||
const isCustom = useMediaQuery('(prefers-contrast: custom)', mediaOptions);
|
||||
const isNoPreference = useMediaQuery('(prefers-contrast: no-preference)', mediaOptions);
|
||||
|
||||
return computed<ContrastType>(() => {
|
||||
if (isMore.value)
|
||||
return 'more';
|
||||
if (isLess.value)
|
||||
return 'less';
|
||||
if (isCustom.value)
|
||||
return 'custom';
|
||||
// When no `prefers-contrast` query matches we're either on a browser that
|
||||
// does not report a preference, or rendering on the server. Distinguish the
|
||||
// explicit `no-preference` match from the unknown/SSR case via the fallback.
|
||||
if (isNoPreference.value)
|
||||
return 'no-preference';
|
||||
return ssrContrast;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope } from 'vue';
|
||||
import { usePreferredLanguages } from '.';
|
||||
import type * as Types from '@/types';
|
||||
|
||||
type Listener = (event: Event) => void;
|
||||
|
||||
interface StubWindow {
|
||||
navigator: { languages: readonly string[] };
|
||||
addEventListener: (type: string, cb: Listener, options?: unknown) => void;
|
||||
removeEventListener: (type: string, cb: Listener) => void;
|
||||
dispatch: () => void;
|
||||
listenerCount: () => number;
|
||||
lastOptions: () => unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a minimal stub `window` whose `navigator.languages` is mutable and that
|
||||
* records its `languagechange` listeners so we can simulate the browser event.
|
||||
*/
|
||||
function makeWindow(initial: string[]): StubWindow {
|
||||
const listeners = new Set<Listener>();
|
||||
let languages: readonly string[] = initial;
|
||||
let captured: unknown;
|
||||
|
||||
const stub: StubWindow = {
|
||||
navigator: {
|
||||
get languages() {
|
||||
return languages;
|
||||
},
|
||||
},
|
||||
addEventListener(type, cb, options) {
|
||||
if (type === 'languagechange') {
|
||||
listeners.add(cb);
|
||||
captured = options;
|
||||
}
|
||||
},
|
||||
removeEventListener(type, cb) {
|
||||
if (type === 'languagechange') listeners.delete(cb);
|
||||
},
|
||||
dispatch() {
|
||||
for (const cb of listeners) cb(new Event('languagechange'));
|
||||
},
|
||||
listenerCount: () => listeners.size,
|
||||
lastOptions: () => captured,
|
||||
};
|
||||
|
||||
// Allow tests to mutate languages before dispatching.
|
||||
Object.defineProperty(stub, 'setLanguages', {
|
||||
value: (next: string[]) => { languages = next; },
|
||||
});
|
||||
|
||||
return stub;
|
||||
}
|
||||
|
||||
describe(usePreferredLanguages, () => {
|
||||
it('reads the initial navigator.languages', () => {
|
||||
const win = makeWindow(['en-US', 'en']);
|
||||
const scope = effectScope();
|
||||
let langs: ReturnType<typeof usePreferredLanguages>;
|
||||
scope.run(() => {
|
||||
langs = usePreferredLanguages({ window: win as unknown as Window });
|
||||
});
|
||||
|
||||
expect(langs!.value).toEqual(['en-US', 'en']);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('updates on languagechange', () => {
|
||||
const win = makeWindow(['en']);
|
||||
const scope = effectScope();
|
||||
let langs: ReturnType<typeof usePreferredLanguages>;
|
||||
scope.run(() => {
|
||||
langs = usePreferredLanguages({ window: win as unknown as Window });
|
||||
});
|
||||
|
||||
(win as unknown as { setLanguages: (n: string[]) => void }).setLanguages(['fr-FR', 'fr', 'en']);
|
||||
win.dispatch();
|
||||
|
||||
expect(langs!.value).toEqual(['fr-FR', 'fr', 'en']);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('registers the listener with a passive option', () => {
|
||||
const win = makeWindow(['en']);
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
usePreferredLanguages({ window: win as unknown as Window });
|
||||
});
|
||||
|
||||
expect(win.listenerCount()).toBe(1);
|
||||
expect(win.lastOptions()).toEqual({ passive: true });
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('removes the listener when the scope is disposed', () => {
|
||||
const win = makeWindow(['en']);
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
usePreferredLanguages({ window: win as unknown as Window });
|
||||
});
|
||||
|
||||
expect(win.listenerCount()).toBe(1);
|
||||
scope.stop();
|
||||
expect(win.listenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('falls back to ["en"] when no window is available (SSR)', async () => {
|
||||
// `defaultWindow` is import-time captured, so override it via a module mock
|
||||
// to simulate a server environment where no window exists.
|
||||
vi.resetModules();
|
||||
vi.doMock('@/types', async () => {
|
||||
const actual = await vi.importActual<typeof Types>('@/types');
|
||||
return { ...actual, defaultWindow: undefined };
|
||||
});
|
||||
|
||||
const { usePreferredLanguages: ssrUsePreferredLanguages } = await import('.');
|
||||
|
||||
const scope = effectScope();
|
||||
let langs: ReturnType<typeof ssrUsePreferredLanguages>;
|
||||
scope.run(() => {
|
||||
langs = ssrUsePreferredLanguages();
|
||||
});
|
||||
|
||||
expect(langs!.value).toEqual(['en']);
|
||||
scope.stop();
|
||||
|
||||
vi.doUnmock('@/types');
|
||||
vi.resetModules();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ShallowRef } from 'vue';
|
||||
import { shallowRef } from 'vue';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
|
||||
/**
|
||||
* @name usePreferredLanguages
|
||||
* @category Browser
|
||||
* @description Reactive `navigator.languages`. Tracks the user's preferred languages and
|
||||
* updates automatically whenever the browser emits a `languagechange` event.
|
||||
*
|
||||
* Falls back to `['en']` during SSR or when no `window` is available, so the returned
|
||||
* value is always a non-empty array.
|
||||
*
|
||||
* @param {ConfigurableWindow} [options={}] Options
|
||||
* @returns {ShallowRef<readonly string[]>} Reactive list of the user's preferred languages
|
||||
*
|
||||
* @example
|
||||
* const languages = usePreferredLanguages();
|
||||
* // -> ['en-US', 'en', 'fr']
|
||||
*
|
||||
* @example
|
||||
* // Pass a custom window (e.g. an iframe)
|
||||
* const languages = usePreferredLanguages({ window: iframe.contentWindow });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function usePreferredLanguages(options: ConfigurableWindow = {}): ShallowRef<readonly string[]> {
|
||||
const { window = defaultWindow } = options;
|
||||
|
||||
if (!window)
|
||||
return shallowRef<readonly string[]>(['en']);
|
||||
|
||||
const navigator = window.navigator;
|
||||
const value = shallowRef<readonly string[]>(navigator.languages);
|
||||
|
||||
useEventListener(window, 'languagechange', () => {
|
||||
value.value = navigator.languages;
|
||||
}, { passive: true });
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick } from 'vue';
|
||||
import { usePreferredReducedMotion } from '.';
|
||||
|
||||
describe(usePreferredReducedMotion, () => {
|
||||
beforeEach(() => vi.stubGlobal('matchMedia', undefined));
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('resolves to "reduce" when the query matches', async () => {
|
||||
vi.stubGlobal('matchMedia', vi.fn((media: string) => ({
|
||||
matches: media.includes('reduce'),
|
||||
media,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
})));
|
||||
|
||||
const scope = effectScope();
|
||||
let motion: ReturnType<typeof usePreferredReducedMotion>;
|
||||
scope.run(() => {
|
||||
motion = usePreferredReducedMotion();
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(motion!.value).toBe('reduce');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('resolves to "no-preference" when the query does not match', async () => {
|
||||
vi.stubGlobal('matchMedia', vi.fn((media: string) => ({
|
||||
matches: false,
|
||||
media,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
})));
|
||||
|
||||
const scope = effectScope();
|
||||
let motion: ReturnType<typeof usePreferredReducedMotion>;
|
||||
scope.run(() => {
|
||||
motion = usePreferredReducedMotion();
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(motion!.value).toBe('no-preference');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reacts to media query changes', async () => {
|
||||
const listeners = new Set<(event: { matches: boolean }) => void>();
|
||||
let currentMatches = false;
|
||||
|
||||
vi.stubGlobal('matchMedia', vi.fn((media: string) => ({
|
||||
get matches() {
|
||||
return currentMatches;
|
||||
},
|
||||
media,
|
||||
addEventListener: (_: string, handler: (event: { matches: boolean }) => void) => listeners.add(handler),
|
||||
removeEventListener: (_: string, handler: (event: { matches: boolean }) => void) => listeners.delete(handler),
|
||||
})));
|
||||
|
||||
const scope = effectScope();
|
||||
let motion: ReturnType<typeof usePreferredReducedMotion>;
|
||||
scope.run(() => {
|
||||
motion = usePreferredReducedMotion();
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(motion!.value).toBe('no-preference');
|
||||
|
||||
currentMatches = true;
|
||||
listeners.forEach(handler => handler({ matches: true }));
|
||||
await nextTick();
|
||||
|
||||
expect(motion!.value).toBe('reduce');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('falls back to "no-preference" when matchMedia is unsupported (SSR)', async () => {
|
||||
// matchMedia is left undefined from beforeEach.
|
||||
const scope = effectScope();
|
||||
let motion: ReturnType<typeof usePreferredReducedMotion>;
|
||||
scope.run(() => {
|
||||
motion = usePreferredReducedMotion();
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(motion!.value).toBe('no-preference');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('returns "no-preference" when window is explicitly undefined (SSR)', async () => {
|
||||
const scope = effectScope();
|
||||
let motion: ReturnType<typeof usePreferredReducedMotion>;
|
||||
scope.run(() => {
|
||||
motion = usePreferredReducedMotion({ window: undefined });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(motion!.value).toBe('no-preference');
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { computed } from 'vue';
|
||||
import type { ComputedRef } from 'vue';
|
||||
import { useMediaQuery } from '@/composables/browser/useMediaQuery';
|
||||
import type { UseMediaQueryOptions } from '@/composables/browser/useMediaQuery';
|
||||
|
||||
export type ReducedMotionType
|
||||
= | 'reduce'
|
||||
| 'no-preference';
|
||||
|
||||
export type UsePreferredReducedMotionOptions = UseMediaQueryOptions;
|
||||
|
||||
export type UsePreferredReducedMotionReturn = ComputedRef<ReducedMotionType>;
|
||||
|
||||
/**
|
||||
* @name usePreferredReducedMotion
|
||||
* @category Browser
|
||||
* @description Reactive `prefers-reduced-motion` media query, resolving to
|
||||
* `'reduce'` when the user requests reduced motion and `'no-preference'`
|
||||
* otherwise. SSR-safe via {@link useMediaQuery}.
|
||||
*
|
||||
* @param {UsePreferredReducedMotionOptions} [options={}] Options (custom `window`)
|
||||
* @returns {UsePreferredReducedMotionReturn} Readonly ref of the current motion preference
|
||||
*
|
||||
* @example
|
||||
* const motion = usePreferredReducedMotion();
|
||||
* // motion.value === 'reduce' | 'no-preference'
|
||||
*
|
||||
* @example
|
||||
* watchEffect(() => {
|
||||
* transitionDuration.value = motion.value === 'reduce' ? 0 : 200;
|
||||
* });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function usePreferredReducedMotion(
|
||||
options: UsePreferredReducedMotionOptions = {},
|
||||
): UsePreferredReducedMotionReturn {
|
||||
const isReduced = useMediaQuery('(prefers-reduced-motion: reduce)', options);
|
||||
|
||||
return computed<ReducedMotionType>(() => isReduced.value ? 'reduce' : 'no-preference');
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick } from 'vue';
|
||||
import { usePreferredReducedTransparency } from '.';
|
||||
|
||||
describe(usePreferredReducedTransparency, () => {
|
||||
beforeEach(() => vi.stubGlobal('matchMedia', undefined));
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('resolves to "reduce" when the media query matches', async () => {
|
||||
vi.stubGlobal('matchMedia', vi.fn((media: string) => ({
|
||||
matches: media.includes('reduce'),
|
||||
media,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
})));
|
||||
|
||||
const scope = effectScope();
|
||||
let transparency: ReturnType<typeof usePreferredReducedTransparency>;
|
||||
scope.run(() => {
|
||||
transparency = usePreferredReducedTransparency();
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(transparency!.value).toBe('reduce');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('resolves to "no-preference" when the media query does not match', async () => {
|
||||
vi.stubGlobal('matchMedia', vi.fn((media: string) => ({
|
||||
matches: false,
|
||||
media,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
})));
|
||||
|
||||
const scope = effectScope();
|
||||
let transparency: ReturnType<typeof usePreferredReducedTransparency>;
|
||||
scope.run(() => {
|
||||
transparency = usePreferredReducedTransparency();
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(transparency!.value).toBe('no-preference');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('defaults to "no-preference" when matchMedia is unsupported (SSR)', async () => {
|
||||
const scope = effectScope();
|
||||
let transparency: ReturnType<typeof usePreferredReducedTransparency>;
|
||||
scope.run(() => {
|
||||
transparency = usePreferredReducedTransparency({ window: undefined });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(transparency!.value).toBe('no-preference');
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { computed } from 'vue';
|
||||
import type { ComputedRef } from 'vue';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { useMediaQuery } from '@/composables/browser/useMediaQuery';
|
||||
|
||||
export type ReducedTransparencyType
|
||||
= 'reduce' | 'no-preference';
|
||||
|
||||
/**
|
||||
* @name usePreferredReducedTransparency
|
||||
* @category Browser
|
||||
* @description Reactive `prefers-reduced-transparency` media query, resolving to
|
||||
* `'reduce'` or `'no-preference'`. SSR-safe (defaults to `'no-preference'`).
|
||||
*
|
||||
* @param {ConfigurableWindow} [options={}] Options (custom `window`)
|
||||
* @returns {ComputedRef<ReducedTransparencyType>} Readonly ref of the user's transparency preference
|
||||
*
|
||||
* @example
|
||||
* const transparency = usePreferredReducedTransparency();
|
||||
* // transparency.value === 'reduce' | 'no-preference'
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function usePreferredReducedTransparency(
|
||||
options: ConfigurableWindow = {},
|
||||
): ComputedRef<ReducedTransparencyType> {
|
||||
const isReduced = useMediaQuery('(prefers-reduced-transparency: reduce)', options);
|
||||
|
||||
return computed<ReducedTransparencyType>(() => isReduced.value ? 'reduce' : 'no-preference');
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope } from 'vue';
|
||||
import { useScriptTag } from '.';
|
||||
|
||||
const SRC = 'https://example.com/sdk.js';
|
||||
|
||||
function flushAll(): void {
|
||||
// Fire `load` on every script tag currently in the head so awaited promises resolve.
|
||||
document.head.querySelectorAll('script').forEach((el) => {
|
||||
el.setAttribute('data-loaded', 'true');
|
||||
el.dispatchEvent(new Event('load'));
|
||||
});
|
||||
}
|
||||
|
||||
describe(useScriptTag, () => {
|
||||
afterEach(() => {
|
||||
document.head.querySelectorAll('script').forEach(el => el.remove());
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('injects a script tag with the configured attributes', async () => {
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useScriptTag>;
|
||||
scope.run(() => {
|
||||
result = useScriptTag(SRC, undefined, {
|
||||
manual: true,
|
||||
type: 'module',
|
||||
defer: true,
|
||||
crossOrigin: 'anonymous',
|
||||
referrerPolicy: 'no-referrer',
|
||||
noModule: true,
|
||||
nonce: 'abc123',
|
||||
attrs: { 'data-test': 'yes', id: 'my-script' },
|
||||
});
|
||||
});
|
||||
|
||||
const promise = result.load();
|
||||
const el = document.querySelector<HTMLScriptElement>(`script[src="${SRC}"]`)!;
|
||||
expect(el).toBeInstanceOf(HTMLScriptElement);
|
||||
expect(el.type).toBe('module');
|
||||
expect(el.defer).toBeTruthy();
|
||||
expect(el.crossOrigin).toBe('anonymous');
|
||||
expect(el.referrerPolicy).toBe('no-referrer');
|
||||
expect(el.noModule).toBeTruthy();
|
||||
expect(el.nonce).toBe('abc123');
|
||||
expect(el.getAttribute('data-test')).toBe('yes');
|
||||
expect(el.getAttribute('id')).toBe('my-script');
|
||||
expect(el.parentElement).toBe(document.head);
|
||||
|
||||
flushAll();
|
||||
await promise;
|
||||
expect(result.scriptTag.value).toBe(el);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('defaults to async and text/javascript', async () => {
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useScriptTag>;
|
||||
scope.run(() => {
|
||||
result = useScriptTag(SRC, undefined, { manual: true });
|
||||
});
|
||||
|
||||
const promise = result.load(false);
|
||||
const el = await promise as HTMLScriptElement;
|
||||
expect(el.async).toBeTruthy();
|
||||
expect(el.type).toBe('text/javascript');
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('calls onLoaded and resolves with the element when the script loads', async () => {
|
||||
const onLoaded = vi.fn();
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useScriptTag>;
|
||||
scope.run(() => {
|
||||
result = useScriptTag(SRC, onLoaded, { manual: true });
|
||||
});
|
||||
|
||||
const promise = result.load();
|
||||
flushAll();
|
||||
const el = await promise;
|
||||
|
||||
expect(onLoaded).toHaveBeenCalledTimes(1);
|
||||
expect(onLoaded).toHaveBeenCalledWith(el);
|
||||
expect(result.scriptTag.value).toBe(el);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('resolves immediately when waitForScriptLoad is false', async () => {
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useScriptTag>;
|
||||
scope.run(() => {
|
||||
result = useScriptTag(SRC, undefined, { manual: true });
|
||||
});
|
||||
|
||||
const el = await result.load(false);
|
||||
expect(el).toBeInstanceOf(HTMLScriptElement);
|
||||
expect(result.scriptTag.value).toBe(el);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('de-duplicates concurrent load calls into a single promise', () => {
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useScriptTag>;
|
||||
scope.run(() => {
|
||||
result = useScriptTag(SRC, undefined, { manual: true });
|
||||
});
|
||||
|
||||
const a = result.load();
|
||||
const b = result.load();
|
||||
expect(a).toBe(b);
|
||||
expect(document.querySelectorAll(`script[src="${SRC}"]`)).toHaveLength(1);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reuses an existing already-loaded script tag', async () => {
|
||||
// Pre-existing, already-loaded tag in the DOM.
|
||||
const existing = document.createElement('script');
|
||||
existing.src = SRC;
|
||||
existing.setAttribute('data-loaded', 'true');
|
||||
document.head.appendChild(existing);
|
||||
|
||||
const onLoaded = vi.fn();
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useScriptTag>;
|
||||
scope.run(() => {
|
||||
result = useScriptTag(SRC, onLoaded, { manual: true });
|
||||
});
|
||||
|
||||
const el = await result.load();
|
||||
expect(el).toBe(existing);
|
||||
expect(result.scriptTag.value).toBe(existing);
|
||||
// Only the original tag is present (no duplicate appended).
|
||||
expect(document.querySelectorAll(`script[src="${SRC}"]`)).toHaveLength(1);
|
||||
// onLoaded should not fire for the short-circuit path.
|
||||
expect(onLoaded).not.toHaveBeenCalled();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('unload removes the tag and clears the ref', async () => {
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useScriptTag>;
|
||||
scope.run(() => {
|
||||
result = useScriptTag(SRC, undefined, { manual: true });
|
||||
});
|
||||
|
||||
await result.load(false);
|
||||
expect(document.querySelector(`script[src="${SRC}"]`)).not.toBeNull();
|
||||
|
||||
result.unload();
|
||||
expect(document.querySelector(`script[src="${SRC}"]`)).toBeNull();
|
||||
expect(result.scriptTag.value).toBeNull();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('unload resets the load promise so the script can be re-loaded', async () => {
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useScriptTag>;
|
||||
scope.run(() => {
|
||||
result = useScriptTag(SRC, undefined, { manual: true });
|
||||
});
|
||||
|
||||
const first = result.load();
|
||||
result.unload();
|
||||
const second = result.load();
|
||||
expect(first).not.toBe(second);
|
||||
expect(document.querySelector(`script[src="${SRC}"]`)).not.toBeNull();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('removes the tag on scope dispose (non-manual)', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
useScriptTag(SRC);
|
||||
});
|
||||
|
||||
// Outside a component instance, tryOnMounted runs synchronously -> tag injected.
|
||||
expect(document.querySelector(`script[src="${SRC}"]`)).not.toBeNull();
|
||||
|
||||
scope.stop();
|
||||
expect(document.querySelector(`script[src="${SRC}"]`)).toBeNull();
|
||||
});
|
||||
|
||||
it('manual mode does not auto-load on mount', () => {
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useScriptTag>;
|
||||
scope.run(() => {
|
||||
result = useScriptTag(SRC, undefined, { manual: true });
|
||||
});
|
||||
|
||||
expect(document.querySelector(`script[src="${SRC}"]`)).toBeNull();
|
||||
expect(result.scriptTag.value).toBeNull();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('immediate: false defers loading until load() is called', () => {
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useScriptTag>;
|
||||
scope.run(() => {
|
||||
result = useScriptTag(SRC, undefined, { immediate: false });
|
||||
});
|
||||
|
||||
expect(document.querySelector(`script[src="${SRC}"]`)).toBeNull();
|
||||
|
||||
result.load(false);
|
||||
expect(document.querySelector(`script[src="${SRC}"]`)).not.toBeNull();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('rejects when the script errors', async () => {
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useScriptTag>;
|
||||
scope.run(() => {
|
||||
result = useScriptTag(SRC, undefined, { manual: true });
|
||||
});
|
||||
|
||||
const promise = result.load();
|
||||
const el = document.querySelector<HTMLScriptElement>(`script[src="${SRC}"]`)!;
|
||||
el.dispatchEvent(new Event('error'));
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(Event);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('SSR / unsupported path: resolves false and never throws when document is absent', async () => {
|
||||
// Note: `defaultDocument` is import-time captured and the destructuring default
|
||||
// `= defaultDocument` only triggers for `undefined`, so we force a falsy document
|
||||
// (mirroring a real SSR environment where `defaultDocument` itself is undefined).
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useScriptTag>;
|
||||
expect(() => {
|
||||
scope.run(() => {
|
||||
result = useScriptTag(SRC, undefined, { document: null as unknown as Document, manual: true });
|
||||
});
|
||||
}).not.toThrow();
|
||||
|
||||
// eslint-disable-next-line vitest/prefer-to-be-falsy -- assert the exact boolean `false`, not any falsy value
|
||||
await expect(result.load()).resolves.toBe(false);
|
||||
expect(() => result.unload()).not.toThrow();
|
||||
expect(result.scriptTag.value).toBeNull();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('returns the documented shape', () => {
|
||||
const result = useScriptTag(SRC, undefined, { manual: true });
|
||||
expect(result.scriptTag.value).toBeNull();
|
||||
expect(typeof result.load).toBe('function');
|
||||
expect(typeof result.unload).toBe('function');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
import { shallowRef, toValue } from 'vue';
|
||||
import type { MaybeRefOrGetter, ShallowRef } from 'vue';
|
||||
import { noop } from '@robonen/stdlib';
|
||||
import { defaultDocument } from '@/types';
|
||||
import type { ConfigurableDocument } from '@/types';
|
||||
import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
|
||||
export type ScriptReferrerPolicy
|
||||
= | 'no-referrer'
|
||||
| 'no-referrer-when-downgrade'
|
||||
| 'origin'
|
||||
| 'origin-when-cross-origin'
|
||||
| 'same-origin'
|
||||
| 'strict-origin'
|
||||
| 'strict-origin-when-cross-origin'
|
||||
| 'unsafe-url';
|
||||
|
||||
export interface UseScriptTagOptions extends ConfigurableDocument {
|
||||
/**
|
||||
* Load the script immediately on mount.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
immediate?: boolean;
|
||||
|
||||
/**
|
||||
* Add the `async` attribute to the script tag.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
async?: boolean;
|
||||
|
||||
/**
|
||||
* Script `type` attribute.
|
||||
*
|
||||
* @default 'text/javascript'
|
||||
*/
|
||||
type?: string;
|
||||
|
||||
/**
|
||||
* Take manual control of the timing of loading and unloading. When `true`,
|
||||
* the script is neither loaded on mount nor unloaded on scope dispose — call
|
||||
* `load()` / `unload()` yourself.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
manual?: boolean;
|
||||
|
||||
/**
|
||||
* CORS setting for the script tag.
|
||||
*/
|
||||
crossOrigin?: 'anonymous' | 'use-credentials';
|
||||
|
||||
/**
|
||||
* Referrer policy for the script request.
|
||||
*/
|
||||
referrerPolicy?: ScriptReferrerPolicy;
|
||||
|
||||
/**
|
||||
* Add the `nomodule` attribute, so the script is skipped by browsers that
|
||||
* support ES modules.
|
||||
*/
|
||||
noModule?: boolean;
|
||||
|
||||
/**
|
||||
* Add the `defer` attribute to the script tag.
|
||||
*/
|
||||
defer?: boolean;
|
||||
|
||||
/**
|
||||
* Nonce value for CSP (Content Security Policy).
|
||||
*
|
||||
* @default undefined
|
||||
*/
|
||||
nonce?: string;
|
||||
|
||||
/**
|
||||
* Custom attributes applied to the script tag via `setAttribute`.
|
||||
*
|
||||
* @default {}
|
||||
*/
|
||||
attrs?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface UseScriptTagReturn {
|
||||
/**
|
||||
* Reactive reference to the underlying `<script>` element, or `null` when not loaded.
|
||||
*/
|
||||
scriptTag: ShallowRef<HTMLScriptElement | null>;
|
||||
|
||||
/**
|
||||
* Load the script into the document `<head>`.
|
||||
*
|
||||
* @param waitForScriptLoad Resolve once the `load` event fires (default `true`)
|
||||
* rather than immediately after appending the element to the DOM.
|
||||
*/
|
||||
load: (waitForScriptLoad?: boolean) => Promise<HTMLScriptElement | boolean>;
|
||||
|
||||
/**
|
||||
* Remove the `<script>` element from the document `<head>`.
|
||||
*/
|
||||
unload: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useScriptTag
|
||||
* @category Browser
|
||||
* @description Dynamically inject and manage a `<script>` tag. The returned
|
||||
* `load`/`unload` controls append the element to the document `<head>` (reusing
|
||||
* an existing tag with the same `src`) and resolve once the script has loaded.
|
||||
* Loading is de-duplicated, listeners are passive, and everything is SSR-safe.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<string>} src Reactive source URL for the script
|
||||
* @param {(el: HTMLScriptElement) => void} [onLoaded=noop] Called when the script finishes loading
|
||||
* @param {UseScriptTagOptions} [options={}] Options
|
||||
* @param {boolean} [options.immediate=true] Load the script on mount
|
||||
* @param {boolean} [options.async=true] Add the `async` attribute
|
||||
* @param {string} [options.type='text/javascript'] Script `type` attribute
|
||||
* @param {boolean} [options.manual=false] Take manual control of load/unload timing
|
||||
* @param {'anonymous' | 'use-credentials'} [options.crossOrigin] CORS setting
|
||||
* @param {ScriptReferrerPolicy} [options.referrerPolicy] Referrer policy
|
||||
* @param {boolean} [options.noModule] Add the `nomodule` attribute
|
||||
* @param {boolean} [options.defer] Add the `defer` attribute
|
||||
* @param {string} [options.nonce] CSP nonce value
|
||||
* @param {Record<string, string>} [options.attrs={}] Custom attributes for the tag
|
||||
* @param {Document} [options.document=defaultDocument] Custom document instance
|
||||
* @returns {UseScriptTagReturn} `{ scriptTag, load, unload }`
|
||||
*
|
||||
* @example
|
||||
* const { scriptTag, load, unload } = useScriptTag(
|
||||
* 'https://example.com/sdk.js',
|
||||
* (el) => console.log('loaded', el),
|
||||
* );
|
||||
*
|
||||
* @example
|
||||
* // Manual control
|
||||
* const { load, unload } = useScriptTag('https://example.com/a.js', undefined, { manual: true });
|
||||
* await load();
|
||||
* unload();
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useScriptTag(
|
||||
src: MaybeRefOrGetter<string>,
|
||||
onLoaded: (el: HTMLScriptElement) => void = noop,
|
||||
options: UseScriptTagOptions = {},
|
||||
): UseScriptTagReturn {
|
||||
const {
|
||||
immediate = true,
|
||||
manual = false,
|
||||
type = 'text/javascript',
|
||||
async = true,
|
||||
crossOrigin,
|
||||
referrerPolicy,
|
||||
noModule,
|
||||
defer,
|
||||
document = defaultDocument,
|
||||
attrs = {},
|
||||
nonce,
|
||||
} = options;
|
||||
|
||||
const scriptTag = shallowRef<HTMLScriptElement | null>(null);
|
||||
|
||||
let _promise: Promise<HTMLScriptElement | boolean> | null = null;
|
||||
|
||||
const loadScript = (waitForScriptLoad: boolean): Promise<HTMLScriptElement | boolean> =>
|
||||
new Promise<HTMLScriptElement | boolean>((resolve, reject) => {
|
||||
const resolveWithElement = (el: HTMLScriptElement): void => {
|
||||
scriptTag.value = el;
|
||||
resolve(el);
|
||||
};
|
||||
|
||||
// SSR / unsupported: no document to append to.
|
||||
if (!document) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = toValue(src);
|
||||
let shouldAppend = false;
|
||||
let el = document.querySelector<HTMLScriptElement>(`script[src="${url}"]`);
|
||||
|
||||
if (!el) {
|
||||
el = document.createElement('script');
|
||||
el.type = type;
|
||||
el.async = async;
|
||||
el.src = url;
|
||||
|
||||
if (defer) el.defer = defer;
|
||||
if (crossOrigin) el.crossOrigin = crossOrigin;
|
||||
if (noModule) el.noModule = noModule;
|
||||
if (referrerPolicy) el.referrerPolicy = referrerPolicy;
|
||||
if (nonce) el.nonce = nonce;
|
||||
|
||||
for (const [name, value] of Object.entries(attrs))
|
||||
el.setAttribute(name, value);
|
||||
|
||||
shouldAppend = true;
|
||||
}
|
||||
else if (el.hasAttribute('data-loaded')) {
|
||||
// Already loaded — short-circuit.
|
||||
resolveWithElement(el);
|
||||
return;
|
||||
}
|
||||
|
||||
const listenerOptions = { passive: true } as const;
|
||||
|
||||
useEventListener(el, 'error', event => reject(event), listenerOptions);
|
||||
useEventListener(el, 'abort', event => reject(event), listenerOptions);
|
||||
useEventListener(el, 'load', () => {
|
||||
el!.setAttribute('data-loaded', 'true');
|
||||
onLoaded(el!);
|
||||
resolveWithElement(el!);
|
||||
}, listenerOptions);
|
||||
|
||||
if (shouldAppend)
|
||||
el = document.head.appendChild(el);
|
||||
|
||||
if (!waitForScriptLoad)
|
||||
resolveWithElement(el);
|
||||
});
|
||||
|
||||
const load = (waitForScriptLoad = true): Promise<HTMLScriptElement | boolean> => {
|
||||
if (!_promise)
|
||||
_promise = loadScript(waitForScriptLoad);
|
||||
|
||||
return _promise;
|
||||
};
|
||||
|
||||
const unload = (): void => {
|
||||
if (!document) return;
|
||||
|
||||
_promise = null;
|
||||
scriptTag.value = null;
|
||||
|
||||
const el = document.querySelector<HTMLScriptElement>(`script[src="${toValue(src)}"]`);
|
||||
if (el)
|
||||
document.head.removeChild(el);
|
||||
};
|
||||
|
||||
if (immediate && !manual)
|
||||
tryOnMounted(load);
|
||||
|
||||
if (!manual)
|
||||
tryOnScopeDispose(unload);
|
||||
|
||||
return {
|
||||
scriptTag,
|
||||
load,
|
||||
unload,
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,7 @@ 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';
|
||||
import { useSupported } from '@/composables/utilities/useSupported';
|
||||
|
||||
export interface UseShareOptions {
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { effectScope, nextTick, ref } from 'vue';
|
||||
import { useStyleTag } from '.';
|
||||
|
||||
describe(useStyleTag, () => {
|
||||
afterEach(() => {
|
||||
// Clean up any leaked style tags between tests.
|
||||
document.head.querySelectorAll('style[id^="vuetools_styletag_"]').forEach(el => el.remove());
|
||||
document.head.querySelectorAll('style#shared, style#manual, style#mediaq, style#csp').forEach(el => el.remove());
|
||||
});
|
||||
|
||||
it('injects a style tag with the given css', () => {
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useStyleTag>;
|
||||
scope.run(() => {
|
||||
result = useStyleTag('body { color: red }');
|
||||
});
|
||||
|
||||
const el = document.getElementById(result.id) as HTMLStyleElement;
|
||||
expect(el).toBeInstanceOf(HTMLStyleElement);
|
||||
expect(el.textContent).toBe('body { color: red }');
|
||||
expect(el.parentElement).toBe(document.head);
|
||||
expect(result.isLoaded.value).toBeTruthy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('updates the stylesheet when css ref changes', async () => {
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useStyleTag>;
|
||||
scope.run(() => {
|
||||
result = useStyleTag('a { color: red }');
|
||||
});
|
||||
|
||||
result.css.value = 'a { color: blue }';
|
||||
await nextTick();
|
||||
|
||||
const el = document.getElementById(result.id) as HTMLStyleElement;
|
||||
expect(el.textContent).toBe('a { color: blue }');
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('accepts a ref / getter as the css source', async () => {
|
||||
const scope = effectScope();
|
||||
const source = ref('p { margin: 0 }');
|
||||
let result!: ReturnType<typeof useStyleTag>;
|
||||
scope.run(() => {
|
||||
result = useStyleTag(source);
|
||||
});
|
||||
|
||||
const el = document.getElementById(result.id) as HTMLStyleElement;
|
||||
expect(el.textContent).toBe('p { margin: 0 }');
|
||||
|
||||
source.value = 'p { margin: 1px }';
|
||||
await nextTick();
|
||||
expect(el.textContent).toBe('p { margin: 1px }');
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('applies media and nonce attributes', () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
useStyleTag('.q {}', { id: 'mediaq', media: 'screen and (max-width: 600px)' });
|
||||
useStyleTag('.c {}', { id: 'csp', nonce: 'abc123' });
|
||||
});
|
||||
|
||||
const mediaEl = document.getElementById('mediaq') as HTMLStyleElement;
|
||||
const cspEl = document.getElementById('csp') as HTMLStyleElement;
|
||||
expect(mediaEl.media).toBe('screen and (max-width: 600px)');
|
||||
expect(cspEl.nonce).toBe('abc123');
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('removes the tag on scope dispose', () => {
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useStyleTag>;
|
||||
scope.run(() => {
|
||||
result = useStyleTag('.x {}');
|
||||
});
|
||||
|
||||
expect(document.getElementById(result.id)).not.toBeNull();
|
||||
scope.stop();
|
||||
expect(document.getElementById(result.id)).toBeNull();
|
||||
});
|
||||
|
||||
it('reference-counts a shared id and only removes when last is unloaded', () => {
|
||||
const a = useStyleTag('.shared { color: red }', { id: 'shared', manual: true });
|
||||
const b = useStyleTag('.shared { color: red }', { id: 'shared', manual: true });
|
||||
|
||||
a.load();
|
||||
b.load();
|
||||
expect(document.getElementById('shared')).not.toBeNull();
|
||||
|
||||
a.unload();
|
||||
// Still present — b holds a reference.
|
||||
expect(document.getElementById('shared')).not.toBeNull();
|
||||
expect(b.isLoaded.value).toBeTruthy();
|
||||
|
||||
b.unload();
|
||||
expect(document.getElementById('shared')).toBeNull();
|
||||
});
|
||||
|
||||
it('manual mode does not auto-load', () => {
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useStyleTag>;
|
||||
scope.run(() => {
|
||||
result = useStyleTag('.manual {}', { id: 'manual', manual: true });
|
||||
});
|
||||
|
||||
expect(result.isLoaded.value).toBeFalsy();
|
||||
expect(document.getElementById('manual')).toBeNull();
|
||||
|
||||
result.load();
|
||||
expect(result.isLoaded.value).toBeTruthy();
|
||||
expect(document.getElementById('manual')).not.toBeNull();
|
||||
|
||||
result.unload();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('load is idempotent (does not double reference-count)', () => {
|
||||
const result = useStyleTag('.dup {}', { manual: true });
|
||||
result.load();
|
||||
result.load();
|
||||
result.load();
|
||||
|
||||
// A single unload removes it because the ref count was incremented only once.
|
||||
result.unload();
|
||||
expect(document.getElementById(result.id)).toBeNull();
|
||||
});
|
||||
|
||||
it('immediate: false defers loading until load() is called outside a component', () => {
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useStyleTag>;
|
||||
scope.run(() => {
|
||||
result = useStyleTag('.imm {}', { immediate: false });
|
||||
});
|
||||
|
||||
// Without a component instance, tryOnMounted runs synchronously; immediate: false
|
||||
// means load is not registered, so nothing is injected yet.
|
||||
expect(result.isLoaded.value).toBeFalsy();
|
||||
|
||||
result.load();
|
||||
expect(result.isLoaded.value).toBeTruthy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('is SSR-safe / unsupported path: never throws when document is absent', () => {
|
||||
// Passing `document: undefined` exercises the configurable-document fallback
|
||||
// path; in a real SSR environment `defaultDocument` is itself undefined and
|
||||
// `load`/`unload` early-return. We assert the construction never throws and a
|
||||
// stable id is still produced.
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useStyleTag>;
|
||||
expect(() => {
|
||||
scope.run(() => {
|
||||
result = useStyleTag('.ssr {}', { document: undefined });
|
||||
});
|
||||
}).not.toThrow();
|
||||
expect(result.id).toMatch(/^vuetools_styletag_/);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('returns the documented shape', () => {
|
||||
const result = useStyleTag('.shape {}', { manual: true });
|
||||
expect(typeof result.id).toBe('string');
|
||||
expect(typeof result.css.value).toBe('string');
|
||||
expect(typeof result.load).toBe('function');
|
||||
expect(typeof result.unload).toBe('function');
|
||||
expect(typeof result.isLoaded.value).toBe('boolean');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { shallowReadonly, shallowRef, toRef, watch } from 'vue';
|
||||
import type { MaybeRefOrGetter, ShallowRef } from 'vue';
|
||||
import { defaultDocument } from '@/types';
|
||||
import type { ConfigurableDocument } from '@/types';
|
||||
import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
|
||||
export interface UseStyleTagOptions extends ConfigurableDocument {
|
||||
/**
|
||||
* Media query applied to the `<style>` element (e.g. `'screen and (max-width: 600px)'`).
|
||||
*/
|
||||
media?: string;
|
||||
|
||||
/**
|
||||
* Load the style immediately on mount.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
immediate?: boolean;
|
||||
|
||||
/**
|
||||
* Take manual control of the timing of loading and unloading. When `true`,
|
||||
* the style is neither loaded on mount nor unloaded on scope dispose — call
|
||||
* `load()` / `unload()` yourself.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
manual?: boolean;
|
||||
|
||||
/**
|
||||
* DOM `id` of the `<style>` tag. Sharing an `id` across instances reuses the
|
||||
* same element and reference-counts it.
|
||||
*
|
||||
* @default auto-incremented (`vuetools_styletag_N`)
|
||||
*/
|
||||
id?: string;
|
||||
|
||||
/**
|
||||
* Nonce value for CSP (Content Security Policy).
|
||||
*
|
||||
* @default undefined
|
||||
*/
|
||||
nonce?: string;
|
||||
}
|
||||
|
||||
export interface UseStyleTagReturn {
|
||||
/**
|
||||
* DOM `id` of the injected `<style>` tag.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Reactive, writable CSS text content of the tag.
|
||||
*/
|
||||
css: ShallowRef<string>;
|
||||
|
||||
/**
|
||||
* Inject the `<style>` tag into `<head>` (or reuse an existing one).
|
||||
*/
|
||||
load: () => void;
|
||||
|
||||
/**
|
||||
* Remove the `<style>` tag from `<head>` when no other instance references it.
|
||||
*/
|
||||
unload: () => void;
|
||||
|
||||
/**
|
||||
* Whether the tag is currently loaded.
|
||||
*/
|
||||
isLoaded: Readonly<ShallowRef<boolean>>;
|
||||
}
|
||||
|
||||
let _id = 0;
|
||||
const _refCount = new WeakMap<HTMLStyleElement, number>();
|
||||
|
||||
/**
|
||||
* @name useStyleTag
|
||||
* @category Browser
|
||||
* @description Inject a reactive `<style>` tag into the document `<head>`. The
|
||||
* CSS is a writable ref — assigning to it updates the live stylesheet. Multiple
|
||||
* instances sharing an `id` reuse a single element via reference counting, and
|
||||
* everything is SSR-safe.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<string>} css Reactive CSS source for the tag
|
||||
* @param {UseStyleTagOptions} [options={}] Options
|
||||
* @param {string} [options.media] Media query applied to the tag
|
||||
* @param {boolean} [options.immediate=true] Load the style on mount
|
||||
* @param {boolean} [options.manual=false] Take manual control of load/unload timing
|
||||
* @param {string} [options.id] DOM id of the tag (default auto-incremented)
|
||||
* @param {string} [options.nonce] CSP nonce value
|
||||
* @param {Document} [options.document=defaultDocument] Custom document instance
|
||||
* @returns {UseStyleTagReturn} `{ id, css, load, unload, isLoaded }`
|
||||
*
|
||||
* @example
|
||||
* const { css, isLoaded } = useStyleTag('body { color: red }');
|
||||
* css.value = 'body { color: blue }';
|
||||
*
|
||||
* @example
|
||||
* // Manual control
|
||||
* const { load, unload } = useStyleTag('.a { color: red }', { manual: true });
|
||||
* load();
|
||||
* unload();
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useStyleTag(
|
||||
css: MaybeRefOrGetter<string>,
|
||||
options: UseStyleTagOptions = {},
|
||||
): UseStyleTagReturn {
|
||||
const {
|
||||
document = defaultDocument,
|
||||
immediate = true,
|
||||
manual = false,
|
||||
id = `vuetools_styletag_${++_id}`,
|
||||
media,
|
||||
nonce,
|
||||
} = options;
|
||||
|
||||
const cssRef = toRef(css);
|
||||
const isLoaded = shallowRef(false);
|
||||
|
||||
let stop = (): void => {};
|
||||
|
||||
const load = (): void => {
|
||||
if (!document) return;
|
||||
|
||||
const el = (document.getElementById(id) ?? document.createElement('style')) as HTMLStyleElement;
|
||||
|
||||
if (!el.isConnected) {
|
||||
el.id = id;
|
||||
if (nonce) el.nonce = nonce;
|
||||
if (media) el.media = media;
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
|
||||
if (isLoaded.value) return;
|
||||
|
||||
_refCount.set(el, (_refCount.get(el) ?? 0) + 1);
|
||||
|
||||
stop = watch(
|
||||
cssRef,
|
||||
(value) => {
|
||||
el.textContent = value;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
isLoaded.value = true;
|
||||
};
|
||||
|
||||
const unload = (): void => {
|
||||
if (!document || !isLoaded.value) return;
|
||||
|
||||
stop();
|
||||
stop = (): void => {};
|
||||
|
||||
const el = document.getElementById(id) as HTMLStyleElement | null;
|
||||
if (el) {
|
||||
const count = (_refCount.get(el) ?? 1) - 1;
|
||||
if (count <= 0) {
|
||||
_refCount.delete(el);
|
||||
document.head.removeChild(el);
|
||||
}
|
||||
else {
|
||||
_refCount.set(el, count);
|
||||
}
|
||||
}
|
||||
|
||||
isLoaded.value = false;
|
||||
};
|
||||
|
||||
if (immediate && !manual)
|
||||
tryOnMounted(load);
|
||||
|
||||
if (!manual)
|
||||
tryOnScopeDispose(unload);
|
||||
|
||||
return {
|
||||
id,
|
||||
css: cssRef,
|
||||
load,
|
||||
unload,
|
||||
isLoaded: shallowReadonly(isLoaded),
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readonly, ref } from 'vue';
|
||||
import type { ComputedRef, DeepReadonly, Ref } from 'vue';
|
||||
import { useSupported } from '@/composables/browser/useSupported';
|
||||
import { useSupported } from '@/composables/utilities/useSupported';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
|
||||
export interface UseTabLeaderOptions {
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick, ref } from 'vue';
|
||||
import { useTextareaAutosize } from '.';
|
||||
|
||||
let instances: Array<{ cb: ResizeObserverCallback; observe: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn> }> = [];
|
||||
|
||||
class StubResizeObserver {
|
||||
observe = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
unobserve = vi.fn();
|
||||
cb: ResizeObserverCallback;
|
||||
constructor(cb: ResizeObserverCallback) {
|
||||
this.cb = cb;
|
||||
instances.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
// jsdom returns 0 for scrollHeight; force a deterministic value
|
||||
function makeTextarea(scrollHeight = 42): HTMLTextAreaElement {
|
||||
const el = document.createElement('textarea');
|
||||
Object.defineProperty(el, 'scrollHeight', { configurable: true, get: () => scrollHeight });
|
||||
return el;
|
||||
}
|
||||
|
||||
// A window whose rAF runs synchronously so resize callbacks are observable
|
||||
function syncWindow(): Window {
|
||||
return {
|
||||
requestAnimationFrame: (fn: FrameRequestCallback) => {
|
||||
fn(0);
|
||||
return 1;
|
||||
},
|
||||
} as unknown as Window;
|
||||
}
|
||||
|
||||
describe(useTextareaAutosize, () => {
|
||||
beforeEach(() => {
|
||||
instances = [];
|
||||
vi.stubGlobal('ResizeObserver', StubResizeObserver);
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('returns textarea, input, and triggerResize', () => {
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useTextareaAutosize>;
|
||||
scope.run(() => {
|
||||
result = useTextareaAutosize();
|
||||
});
|
||||
|
||||
expect(result.textarea.value).toBeUndefined();
|
||||
expect(result.input.value).toBe('');
|
||||
expect(typeof result.triggerResize).toBe('function');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('seeds input and element from options', () => {
|
||||
const el = makeTextarea();
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useTextareaAutosize>;
|
||||
scope.run(() => {
|
||||
result = useTextareaAutosize({ element: el, input: 'hello' });
|
||||
});
|
||||
|
||||
expect(result.textarea.value).toBe(el);
|
||||
expect(result.input.value).toBe('hello');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('sizes the textarea height to its scrollHeight on resize', () => {
|
||||
const el = makeTextarea(80);
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useTextareaAutosize>;
|
||||
scope.run(() => {
|
||||
result = useTextareaAutosize({ element: el });
|
||||
});
|
||||
|
||||
result.triggerResize();
|
||||
expect(el.style.height).toBe('80px');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('clamps the height to maxHeight', () => {
|
||||
const el = makeTextarea(500);
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useTextareaAutosize>;
|
||||
scope.run(() => {
|
||||
result = useTextareaAutosize({ element: el, maxHeight: 200 });
|
||||
});
|
||||
|
||||
result.triggerResize();
|
||||
expect(el.style.height).toBe('200px');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('applies the configured styleProp', () => {
|
||||
const el = makeTextarea(64);
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useTextareaAutosize>;
|
||||
scope.run(() => {
|
||||
result = useTextareaAutosize({ element: el, styleProp: 'minHeight' });
|
||||
});
|
||||
|
||||
result.triggerResize();
|
||||
expect(el.style.minHeight).toBe('64px');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('sizes the styleTarget instead of the textarea when provided', () => {
|
||||
const el = makeTextarea(70);
|
||||
const wrapper = document.createElement('div');
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useTextareaAutosize>;
|
||||
scope.run(() => {
|
||||
result = useTextareaAutosize({ element: el, styleTarget: wrapper });
|
||||
});
|
||||
|
||||
result.triggerResize();
|
||||
expect(wrapper.style.height).toBe('70px');
|
||||
expect(el.style.height).toBe('');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('resizes on textarea input event', async () => {
|
||||
const el = makeTextarea(90);
|
||||
const scope = effectScope();
|
||||
scope.run(() => useTextareaAutosize({ element: el }));
|
||||
await nextTick();
|
||||
|
||||
el.style.height = '';
|
||||
el.dispatchEvent(new Event('input'));
|
||||
expect(el.style.height).toBe('90px');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('resizes when the bound input ref changes', async () => {
|
||||
const el = makeTextarea(55);
|
||||
const input = ref('a');
|
||||
const scope = effectScope();
|
||||
scope.run(() => useTextareaAutosize({ element: el, input }));
|
||||
await nextTick();
|
||||
|
||||
el.style.height = '';
|
||||
input.value = 'a longer value';
|
||||
// post-flush watch schedules nextTick(triggerResize): flush both
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(el.style.height).toBe('55px');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('invokes onResize when the content height changes', async () => {
|
||||
const el = makeTextarea(40);
|
||||
const onResize = vi.fn();
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useTextareaAutosize>;
|
||||
scope.run(() => {
|
||||
result = useTextareaAutosize({ element: el, onResize });
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
onResize.mockClear();
|
||||
result.triggerResize();
|
||||
// height unchanged -> no extra onResize
|
||||
expect(onResize).not.toHaveBeenCalled();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('observes the textarea via ResizeObserver and resizes on width change', async () => {
|
||||
const el = makeTextarea(60);
|
||||
const scope = effectScope();
|
||||
scope.run(() => useTextareaAutosize({ element: el, window: syncWindow() }));
|
||||
await nextTick();
|
||||
|
||||
expect(instances).toHaveLength(1);
|
||||
expect(instances[0]!.observe).toHaveBeenCalledWith(el, undefined);
|
||||
|
||||
el.style.height = '';
|
||||
instances[0]!.cb([{ contentRect: { width: 123 } } as ResizeObserverEntry], instances[0] as unknown as ResizeObserver);
|
||||
expect(el.style.height).toBe('60px');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('ignores ResizeObserver callbacks with an unchanged width', async () => {
|
||||
const el = makeTextarea(60);
|
||||
const scope = effectScope();
|
||||
scope.run(() => useTextareaAutosize({ element: el, window: syncWindow() }));
|
||||
await nextTick();
|
||||
|
||||
const entry = [{ contentRect: { width: 200 } } as ResizeObserverEntry];
|
||||
instances[0]!.cb(entry, instances[0] as unknown as ResizeObserver);
|
||||
el.style.height = '';
|
||||
// same width again -> should not re-size
|
||||
instances[0]!.cb(entry, instances[0] as unknown as ResizeObserver);
|
||||
expect(el.style.height).toBe('');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('resizes on a custom watch source', async () => {
|
||||
const el = makeTextarea(33);
|
||||
const dep = ref(0);
|
||||
const scope = effectScope();
|
||||
scope.run(() => useTextareaAutosize({ element: el, watch: dep }));
|
||||
await nextTick();
|
||||
|
||||
el.style.height = '';
|
||||
dep.value = 1;
|
||||
await nextTick();
|
||||
expect(el.style.height).toBe('33px');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('triggerResize is a no-op without a textarea (SSR / unmounted path)', () => {
|
||||
const scope = effectScope();
|
||||
let result!: ReturnType<typeof useTextareaAutosize>;
|
||||
scope.run(() => {
|
||||
result = useTextareaAutosize({ window: undefined });
|
||||
});
|
||||
|
||||
// No textarea bound: must not throw
|
||||
expect(() => result.triggerResize()).not.toThrow();
|
||||
expect(instances).toHaveLength(0);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not throw when constructed with no DOM globals supplied via options', () => {
|
||||
const scope = effectScope();
|
||||
expect(() => {
|
||||
scope.run(() => useTextareaAutosize({ window: undefined }));
|
||||
}).not.toThrow();
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { MaybeRef, MultiWatchSources, Ref, WatchSource } from 'vue';
|
||||
import { nextTick, toRef, toValue, watch } from 'vue';
|
||||
import type { VoidFunction } from '@robonen/stdlib';
|
||||
import { noop } from '@robonen/stdlib';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { defaultWindow } from '@/types';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
import { useResizeObserver } from '@/composables/elements/useResizeObserver';
|
||||
|
||||
export interface UseTextareaAutosizeOptions extends ConfigurableWindow {
|
||||
/**
|
||||
* The textarea element to autosize. May also be bound via the returned `textarea` ref
|
||||
*/
|
||||
element?: MaybeRef<HTMLTextAreaElement | undefined | null>;
|
||||
|
||||
/**
|
||||
* The textarea content. May also be bound via the returned `input` ref
|
||||
*/
|
||||
input?: MaybeRef<string>;
|
||||
|
||||
/**
|
||||
* Cap the resulting height (in pixels). Beyond this the textarea scrolls
|
||||
*/
|
||||
maxHeight?: number;
|
||||
|
||||
/**
|
||||
* Additional reactive sources that should trigger a resize when they change
|
||||
*/
|
||||
watch?: WatchSource | MultiWatchSources;
|
||||
|
||||
/**
|
||||
* Invoked after each resize once the textarea height settles
|
||||
*
|
||||
* @default noop
|
||||
*/
|
||||
onResize?: VoidFunction;
|
||||
|
||||
/**
|
||||
* Apply the computed height to this element instead of the textarea itself.
|
||||
* Useful when the textarea is wrapped (e.g. for a CSS grid auto-grow trick)
|
||||
*/
|
||||
styleTarget?: MaybeRef<HTMLElement | undefined | null>;
|
||||
|
||||
/**
|
||||
* Which style property carries the computed height
|
||||
*
|
||||
* @default 'height'
|
||||
*/
|
||||
styleProp?: 'height' | 'minHeight';
|
||||
}
|
||||
|
||||
export interface UseTextareaAutosizeReturn {
|
||||
/**
|
||||
* The textarea element being autosized. Bind it with `ref` in the template
|
||||
*/
|
||||
textarea: Ref<HTMLTextAreaElement | undefined | null>;
|
||||
|
||||
/**
|
||||
* Two-way bound textarea content
|
||||
*/
|
||||
input: Ref<string>;
|
||||
|
||||
/**
|
||||
* Force a resize on demand (e.g. after a programmatic value change)
|
||||
*/
|
||||
triggerResize: VoidFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useTextareaAutosize
|
||||
* @category Browser
|
||||
* @description Auto-resizes a `<textarea>` to fit its content. Reacts to user
|
||||
* input, programmatic content changes, and element resize. Reuses
|
||||
* `useEventListener` for a passive, auto-cleaned `input` listener and
|
||||
* `useResizeObserver` to re-measure when the textarea's width changes (so
|
||||
* reflowed text is re-fitted). SSR safe.
|
||||
*
|
||||
* @param {UseTextareaAutosizeOptions} [options={}] Options
|
||||
* @returns {UseTextareaAutosizeReturn} `textarea`, `input`, and `triggerResize`
|
||||
*
|
||||
* @example
|
||||
* const { textarea, input } = useTextareaAutosize();
|
||||
* // <textarea ref="textarea" v-model="input" />
|
||||
*
|
||||
* @example
|
||||
* const { textarea, input, triggerResize } = useTextareaAutosize({ maxHeight: 320 });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useTextareaAutosize(options: UseTextareaAutosizeOptions = {}): UseTextareaAutosizeReturn {
|
||||
const {
|
||||
window = defaultWindow,
|
||||
maxHeight,
|
||||
styleProp = 'height',
|
||||
onResize = noop,
|
||||
} = options;
|
||||
|
||||
const textarea = toRef(options.element) as Ref<HTMLTextAreaElement | undefined | null>;
|
||||
const input = toRef(options.input ?? '') as Ref<string>;
|
||||
|
||||
// Cached, non-reactive — only used to skip redundant resizes on height-only
|
||||
// ResizeObserver callbacks (we resize on width changes, not our own height edits)
|
||||
let lastWidth = -1;
|
||||
let lastScrollHeight = -1;
|
||||
|
||||
const triggerResize: VoidFunction = () => {
|
||||
const el = textarea.value;
|
||||
|
||||
if (!el)
|
||||
return;
|
||||
|
||||
const target = (toValue(options.styleTarget) ?? el) as HTMLElement;
|
||||
|
||||
// Collapse to a single line so scrollHeight reports the true content height
|
||||
el.style[styleProp] = '1px';
|
||||
|
||||
const scrollHeight = el.scrollHeight;
|
||||
const height = maxHeight !== undefined ? Math.min(scrollHeight, maxHeight) : scrollHeight;
|
||||
|
||||
if (target === el)
|
||||
el.style[styleProp] = `${height}px`;
|
||||
else {
|
||||
// Restore the textarea to its natural value and size the wrapper instead
|
||||
el.style[styleProp] = '';
|
||||
target.style[styleProp] = `${height}px`;
|
||||
}
|
||||
|
||||
if (scrollHeight !== lastScrollHeight) {
|
||||
lastScrollHeight = scrollHeight;
|
||||
onResize();
|
||||
}
|
||||
};
|
||||
|
||||
// React to programmatic content changes and to the element binding itself.
|
||||
// flush 'post' guarantees the DOM (and v-model'd value) is updated before we measure
|
||||
watch(
|
||||
[input, textarea],
|
||||
() => nextTick(triggerResize),
|
||||
{ immediate: true, flush: 'post' },
|
||||
);
|
||||
|
||||
// React to direct user typing without waiting for the bound model to round-trip.
|
||||
// Passive: we never call preventDefault here
|
||||
useEventListener(textarea, 'input', triggerResize, { passive: true });
|
||||
|
||||
// React to width changes (font/box reflow). Ignore pure height deltas — those
|
||||
// are usually our own edits and would otherwise loop
|
||||
useResizeObserver(textarea, (entries) => {
|
||||
const entry = entries[0];
|
||||
|
||||
if (!entry)
|
||||
return;
|
||||
|
||||
const width = entry.contentRect.width;
|
||||
|
||||
if (width === lastWidth)
|
||||
return;
|
||||
|
||||
const run = () => {
|
||||
lastWidth = width;
|
||||
triggerResize();
|
||||
};
|
||||
|
||||
if (window && typeof window.requestAnimationFrame === 'function')
|
||||
window.requestAnimationFrame(run);
|
||||
else
|
||||
run();
|
||||
});
|
||||
|
||||
if (options.watch)
|
||||
watch(options.watch, triggerResize, { immediate: true, deep: true });
|
||||
|
||||
return {
|
||||
textarea,
|
||||
input,
|
||||
triggerResize,
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,7 @@ 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 { useMutationObserver } from '@/composables/elements/useMutationObserver';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
|
||||
export interface UseTitleOptionsBase extends ConfigurableDocument {
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { effectScope, nextTick } from 'vue';
|
||||
import { useUrlSearchParams } from '.';
|
||||
import type { UrlSearchParamsMode, UseUrlSearchParamsOptions } from '.';
|
||||
|
||||
function mockWindow(href: string): Window {
|
||||
const url = new URL(href);
|
||||
const location = {
|
||||
get pathname() {
|
||||
return url.pathname;
|
||||
},
|
||||
get search() {
|
||||
return url.search;
|
||||
},
|
||||
get hash() {
|
||||
return url.hash;
|
||||
},
|
||||
};
|
||||
|
||||
const listeners = new Map<string, Set<(ev: any) => void>>();
|
||||
|
||||
const win = {
|
||||
location,
|
||||
document: { title: 'test' },
|
||||
history: {
|
||||
state: null as any,
|
||||
replaceState(state: any, _title: string, nextUrl: string) {
|
||||
this.state = state;
|
||||
const resolved = new URL(nextUrl, url.origin);
|
||||
url.pathname = resolved.pathname;
|
||||
url.search = resolved.search;
|
||||
url.hash = resolved.hash;
|
||||
},
|
||||
pushState(state: any, _title: string, nextUrl: string) {
|
||||
this.state = state;
|
||||
const resolved = new URL(nextUrl, url.origin);
|
||||
url.pathname = resolved.pathname;
|
||||
url.search = resolved.search;
|
||||
url.hash = resolved.hash;
|
||||
},
|
||||
},
|
||||
addEventListener(type: string, listener: (ev: any) => void) {
|
||||
if (!listeners.has(type))
|
||||
listeners.set(type, new Set());
|
||||
listeners.get(type)!.add(listener);
|
||||
},
|
||||
removeEventListener(type: string, listener: (ev: any) => void) {
|
||||
listeners.get(type)?.delete(listener);
|
||||
},
|
||||
dispatch(type: string) {
|
||||
listeners.get(type)?.forEach(fn => fn({ type }));
|
||||
},
|
||||
// Allow tests to simulate external URL changes
|
||||
navigate(nextHref: string) {
|
||||
const resolved = new URL(nextHref, url.origin);
|
||||
url.pathname = resolved.pathname;
|
||||
url.search = resolved.search;
|
||||
url.hash = resolved.hash;
|
||||
},
|
||||
currentSearch() {
|
||||
return url.search;
|
||||
},
|
||||
currentHash() {
|
||||
return url.hash;
|
||||
},
|
||||
};
|
||||
|
||||
return win as unknown as Window & {
|
||||
dispatch: (type: string) => void;
|
||||
navigate: (href: string) => void;
|
||||
currentSearch: () => string;
|
||||
currentHash: () => string;
|
||||
};
|
||||
}
|
||||
|
||||
function run<T extends Record<string, any>>(
|
||||
mode: UrlSearchParamsMode,
|
||||
options: UseUrlSearchParamsOptions<T>,
|
||||
): { scope: ReturnType<typeof effectScope>; params: T } {
|
||||
const scope = effectScope();
|
||||
let params!: T;
|
||||
scope.run(() => {
|
||||
params = useUrlSearchParams<T>(mode, options);
|
||||
});
|
||||
return { scope, params };
|
||||
}
|
||||
|
||||
describe(useUrlSearchParams, () => {
|
||||
let scopes: Array<ReturnType<typeof effectScope>> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
scopes = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
scopes.forEach(scope => scope.stop());
|
||||
});
|
||||
|
||||
function track(scope: ReturnType<typeof effectScope>) {
|
||||
scopes.push(scope);
|
||||
return scope;
|
||||
}
|
||||
|
||||
it('reads existing history params', () => {
|
||||
const window = mockWindow('http://localhost/?foo=bar&count=1');
|
||||
const { scope, params } = run('history', { window });
|
||||
track(scope);
|
||||
expect(params.foo).toBe('bar');
|
||||
expect(params.count).toBe('1');
|
||||
});
|
||||
|
||||
it('decodes repeated keys into arrays', () => {
|
||||
const window = mockWindow('http://localhost/?id=1&id=2&id=3');
|
||||
const { scope, params } = run<{ id: string[] }>('history', { window });
|
||||
track(scope);
|
||||
expect(params.id).toEqual(['1', '2', '3']);
|
||||
});
|
||||
|
||||
it('writes back to the URL when the state mutates', async () => {
|
||||
const window = mockWindow('http://localhost/') as any;
|
||||
const { scope, params } = run('history', { window });
|
||||
track(scope);
|
||||
|
||||
params.foo = 'bar';
|
||||
await nextTick();
|
||||
expect(window.currentSearch()).toBe('?foo=bar');
|
||||
});
|
||||
|
||||
it('writes array values as repeated keys', async () => {
|
||||
const window = mockWindow('http://localhost/') as any;
|
||||
const { scope, params } = run<{ id: string[] }>('history', { window });
|
||||
track(scope);
|
||||
|
||||
params.id = ['1', '2'];
|
||||
await nextTick();
|
||||
expect(window.currentSearch()).toBe('?id=1&id=2');
|
||||
});
|
||||
|
||||
it('seeds initialValue when the URL has no params', () => {
|
||||
const window = mockWindow('http://localhost/');
|
||||
const { scope, params } = run('history', { window, initialValue: { tab: 'home' } });
|
||||
track(scope);
|
||||
expect(params.tab).toBe('home');
|
||||
});
|
||||
|
||||
it('ignores initialValue when the URL already has params', () => {
|
||||
const window = mockWindow('http://localhost/?tab=settings');
|
||||
const { scope, params } = run('history', { window, initialValue: { tab: 'home' } });
|
||||
track(scope);
|
||||
expect(params.tab).toBe('settings');
|
||||
});
|
||||
|
||||
it('removes nullish values by default', async () => {
|
||||
const window = mockWindow('http://localhost/?foo=bar') as any;
|
||||
const { scope, params } = run<{ foo: any }>('history', { window });
|
||||
track(scope);
|
||||
|
||||
params.foo = null;
|
||||
await nextTick();
|
||||
expect(window.currentSearch()).toBe('');
|
||||
});
|
||||
|
||||
it('keeps falsy values unless removeFalsyValues is set', async () => {
|
||||
const windowKeep = mockWindow('http://localhost/') as any;
|
||||
const kept = run<{ foo: any }>('history', { window: windowKeep });
|
||||
track(kept.scope);
|
||||
kept.params.foo = '';
|
||||
await nextTick();
|
||||
expect(windowKeep.currentSearch()).toBe('?foo=');
|
||||
|
||||
const windowDrop = mockWindow('http://localhost/') as any;
|
||||
const dropped = run<{ foo: any }>('history', { window: windowDrop, removeFalsyValues: true });
|
||||
track(dropped.scope);
|
||||
dropped.params.foo = '';
|
||||
await nextTick();
|
||||
expect(windowDrop.currentSearch()).toBe('');
|
||||
});
|
||||
|
||||
it('syncs state when popstate fires', () => {
|
||||
const window = mockWindow('http://localhost/?foo=bar') as any;
|
||||
const { scope, params } = run('history', { window });
|
||||
track(scope);
|
||||
expect(params.foo).toBe('bar');
|
||||
|
||||
window.navigate('http://localhost/?foo=baz&extra=1');
|
||||
window.dispatch('popstate');
|
||||
expect(params.foo).toBe('baz');
|
||||
expect(params.extra).toBe('1');
|
||||
});
|
||||
|
||||
it('does not write back when write is disabled', () => {
|
||||
const window = mockWindow('http://localhost/?foo=bar') as any;
|
||||
const { scope, params } = run('history', { window, write: false });
|
||||
track(scope);
|
||||
|
||||
window.navigate('http://localhost/?foo=changed');
|
||||
window.dispatch('popstate');
|
||||
// write:false short-circuits onChanged, so state is not updated from the event
|
||||
expect(params.foo).toBe('bar');
|
||||
});
|
||||
|
||||
it('reads hash mode params', () => {
|
||||
const window = mockWindow('http://localhost/#/page?foo=bar');
|
||||
const { scope, params } = run('hash', { window });
|
||||
track(scope);
|
||||
expect(params.foo).toBe('bar');
|
||||
});
|
||||
|
||||
it('writes hash mode params while preserving the hash path', async () => {
|
||||
const window = mockWindow('http://localhost/#/page') as any;
|
||||
const { scope, params } = run('hash', { window });
|
||||
track(scope);
|
||||
|
||||
params.foo = 'bar';
|
||||
await nextTick();
|
||||
expect(window.currentHash()).toBe('#/page?foo=bar');
|
||||
});
|
||||
|
||||
it('reads hash-params mode', () => {
|
||||
const window = mockWindow('http://localhost/#foo=bar&count=2');
|
||||
const { scope, params } = run('hash-params', { window });
|
||||
track(scope);
|
||||
expect(params.foo).toBe('bar');
|
||||
expect(params.count).toBe('2');
|
||||
});
|
||||
|
||||
it('writes hash-params mode', async () => {
|
||||
const window = mockWindow('http://localhost/') as any;
|
||||
const { scope, params } = run('hash-params', { window });
|
||||
track(scope);
|
||||
|
||||
params.foo = 'bar';
|
||||
await nextTick();
|
||||
expect(window.currentHash()).toBe('#foo=bar');
|
||||
});
|
||||
|
||||
it('uses pushState when writeMode is push', async () => {
|
||||
const window = mockWindow('http://localhost/') as any;
|
||||
let pushed = 0;
|
||||
const originalPush = window.history.pushState.bind(window.history);
|
||||
window.history.pushState = function (state: any, title: string, url: string) {
|
||||
pushed++;
|
||||
return originalPush(state, title, url);
|
||||
};
|
||||
|
||||
const { scope, params } = run('history', { window, writeMode: 'push' });
|
||||
track(scope);
|
||||
|
||||
params.foo = 'bar';
|
||||
await nextTick();
|
||||
expect(pushed).toBe(1);
|
||||
expect(window.currentSearch()).toBe('?foo=bar');
|
||||
});
|
||||
|
||||
it('applies a custom stringify', async () => {
|
||||
const window = mockWindow('http://localhost/') as any;
|
||||
const { scope, params } = run('history', {
|
||||
window,
|
||||
stringify: p => p.toString().toUpperCase(),
|
||||
});
|
||||
track(scope);
|
||||
|
||||
params.foo = 'bar';
|
||||
await nextTick();
|
||||
expect(window.currentSearch()).toBe('?FOO=BAR');
|
||||
});
|
||||
|
||||
it('removes deleted keys from the URL', async () => {
|
||||
const window = mockWindow('http://localhost/?a=1&b=2') as any;
|
||||
const { scope, params } = run<{ a?: string; b?: string }>('history', { window });
|
||||
track(scope);
|
||||
|
||||
delete params.b;
|
||||
await nextTick();
|
||||
expect(window.currentSearch()).toBe('?a=1');
|
||||
});
|
||||
|
||||
it('returns a reactive seeded object when window is unavailable (SSR)', () => {
|
||||
const scope = effectScope();
|
||||
track(scope);
|
||||
let params!: { foo: string };
|
||||
scope.run(() => {
|
||||
params = useUrlSearchParams<{ foo: string }>('history', {
|
||||
window: undefined,
|
||||
initialValue: { foo: 'bar' },
|
||||
});
|
||||
});
|
||||
expect(params.foo).toBe('bar');
|
||||
// Mutations are still reactive even without a window
|
||||
params.foo = 'baz';
|
||||
expect(params.foo).toBe('baz');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import { nextTick, reactive } from 'vue';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
import { watchPausable } from '@/composables/watch/watchPausable';
|
||||
|
||||
export type UrlSearchParamsMode = 'history' | 'hash' | 'hash-params';
|
||||
|
||||
export type UrlParams = Record<string, string[] | string>;
|
||||
|
||||
export interface UseUrlSearchParamsOptions<T> extends ConfigurableWindow {
|
||||
/**
|
||||
* Remove keys whose value is `null` or `undefined` from the URL.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
removeNullishValues?: boolean;
|
||||
|
||||
/**
|
||||
* Remove keys whose value is falsy (`''`, `0`, `false`, `null`, `undefined`) from the URL.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
removeFalsyValues?: boolean;
|
||||
|
||||
/**
|
||||
* Seed values applied when the URL has no params on initialization.
|
||||
*
|
||||
* @default {}
|
||||
*/
|
||||
initialValue?: T;
|
||||
|
||||
/**
|
||||
* Write changes back to the URL when the reactive state mutates.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
write?: boolean;
|
||||
|
||||
/**
|
||||
* History method used when writing back to the URL.
|
||||
* - `replace` rewrites the current entry via `history.replaceState`
|
||||
* - `push` adds a new entry via `history.pushState`
|
||||
*
|
||||
* @default 'replace'
|
||||
*/
|
||||
writeMode?: 'replace' | 'push';
|
||||
|
||||
/**
|
||||
* Serialize the params into a query string. Override to control ordering or encoding.
|
||||
*
|
||||
* @default (params) => params.toString()
|
||||
*/
|
||||
stringify?: (params: URLSearchParams) => string;
|
||||
}
|
||||
|
||||
export type UseUrlSearchParamsReturn<T extends Record<string, any> = UrlParams>
|
||||
= T;
|
||||
|
||||
/**
|
||||
* @name useUrlSearchParams
|
||||
* @category Browser
|
||||
* @description Reactive `URLSearchParams` exposed as a plain reactive object. Reads from and
|
||||
* (optionally) writes back to the URL using the `history`, `hash`, or `hash-params` location source.
|
||||
* Listens for `popstate`/`hashchange` with passive listeners and pauses its own writer while syncing
|
||||
* to avoid feedback loops. SSR-safe: returns the seeded reactive object when no `window` is available.
|
||||
*
|
||||
* @param {UrlSearchParamsMode} [mode='history'] Where the params live: `history` (`?a=1`), `hash` (`#/path?a=1`), or `hash-params` (`#a=1`)
|
||||
* @param {UseUrlSearchParamsOptions<T>} [options={}] Behavior options
|
||||
* @returns {UseUrlSearchParamsReturn<T>} A reactive object mirroring the URL params; mutate it to update the URL
|
||||
*
|
||||
* @example
|
||||
* const params = useUrlSearchParams('history');
|
||||
* params.foo = 'bar'; // -> ?foo=bar
|
||||
*
|
||||
* @example
|
||||
* // Hash mode with seeded values and push-history writes
|
||||
* const params = useUrlSearchParams('hash', {
|
||||
* initialValue: { tab: 'home' },
|
||||
* writeMode: 'push',
|
||||
* });
|
||||
*
|
||||
* @example
|
||||
* // Repeated keys decode to arrays
|
||||
* const params = useUrlSearchParams<{ ids: string[] }>('history');
|
||||
* params.ids = ['1', '2']; // -> ?ids=1&ids=2
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useUrlSearchParams<T extends Record<string, any> = UrlParams>(
|
||||
mode: UrlSearchParamsMode = 'history',
|
||||
options: UseUrlSearchParamsOptions<T> = {},
|
||||
): UseUrlSearchParamsReturn<T> {
|
||||
const {
|
||||
initialValue = {} as T,
|
||||
removeNullishValues = true,
|
||||
removeFalsyValues = false,
|
||||
write: enableWrite = true,
|
||||
writeMode = 'replace',
|
||||
window = defaultWindow,
|
||||
stringify = (params: URLSearchParams) => params.toString(),
|
||||
} = options;
|
||||
|
||||
if (!window)
|
||||
return reactive({ ...initialValue }) as UseUrlSearchParamsReturn<T>;
|
||||
|
||||
const state = reactive<Record<string, any>>({});
|
||||
|
||||
const getRawParams = (): string => {
|
||||
if (mode === 'history')
|
||||
return window.location.search || '';
|
||||
|
||||
if (mode === 'hash') {
|
||||
const hash = window.location.hash || '';
|
||||
const index = hash.indexOf('?');
|
||||
return index > 0 ? hash.slice(index) : '';
|
||||
}
|
||||
|
||||
return (window.location.hash || '').replace(/^#/, '');
|
||||
};
|
||||
|
||||
const constructQuery = (params: URLSearchParams): string => {
|
||||
const stringified = stringify(params);
|
||||
|
||||
if (mode === 'history')
|
||||
return `${stringified ? `?${stringified}` : ''}${window.location.hash || ''}`;
|
||||
|
||||
if (mode === 'hash-params')
|
||||
return `${window.location.search || ''}${stringified ? `#${stringified}` : ''}`;
|
||||
|
||||
const hash = window.location.hash || '#';
|
||||
const index = hash.indexOf('?');
|
||||
if (index > 0)
|
||||
return `${window.location.search || ''}${hash.slice(0, index)}${stringified ? `?${stringified}` : ''}`;
|
||||
|
||||
return `${window.location.search || ''}${hash}${stringified ? `?${stringified}` : ''}`;
|
||||
};
|
||||
|
||||
const read = (): URLSearchParams => new URLSearchParams(getRawParams());
|
||||
|
||||
const updateState = (params: URLSearchParams): void => {
|
||||
const unusedKeys = new Set(Object.keys(state));
|
||||
for (const key of params.keys()) {
|
||||
const valuesForKey = params.getAll(key);
|
||||
state[key] = valuesForKey.length > 1 ? valuesForKey : (params.get(key) || '');
|
||||
unusedKeys.delete(key);
|
||||
}
|
||||
for (const key of unusedKeys)
|
||||
delete state[key];
|
||||
};
|
||||
|
||||
const serializeState = (): URLSearchParams => {
|
||||
const params = new URLSearchParams('');
|
||||
for (const key of Object.keys(state)) {
|
||||
const value = state[key];
|
||||
if (Array.isArray(value))
|
||||
value.forEach(item => params.append(key, item));
|
||||
else if (removeNullishValues && (value === null || value === undefined))
|
||||
params.delete(key);
|
||||
else if (removeFalsyValues && !value)
|
||||
params.delete(key);
|
||||
else
|
||||
params.set(key, value);
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
const write = (params: URLSearchParams, shouldUpdateState: boolean, shouldWriteHistory = true): void => {
|
||||
pause();
|
||||
|
||||
if (shouldUpdateState)
|
||||
updateState(params);
|
||||
|
||||
const url = window.location.pathname + constructQuery(params);
|
||||
|
||||
if (writeMode === 'replace')
|
||||
window.history.replaceState(window.history.state, window.document.title, url);
|
||||
else if (shouldWriteHistory)
|
||||
window.history.pushState(window.history.state, window.document.title, url);
|
||||
|
||||
nextTick(() => resume());
|
||||
};
|
||||
|
||||
const { pause, resume } = watchPausable(
|
||||
state,
|
||||
() => write(serializeState(), false),
|
||||
{ deep: true, initialState: 'paused' },
|
||||
);
|
||||
|
||||
const onChanged = (): void => {
|
||||
if (!enableWrite)
|
||||
return;
|
||||
|
||||
write(read(), true, false);
|
||||
};
|
||||
|
||||
const listenerOptions = { passive: true };
|
||||
|
||||
useEventListener(window, 'popstate', onChanged, listenerOptions);
|
||||
if (mode !== 'history')
|
||||
useEventListener(window, 'hashchange', onChanged, listenerOptions);
|
||||
|
||||
const initial = read();
|
||||
if (!initial.keys().next().done)
|
||||
updateState(initial);
|
||||
else
|
||||
Object.assign(state, initialValue);
|
||||
|
||||
resume();
|
||||
|
||||
return state as UseUrlSearchParamsReturn<T>;
|
||||
}
|
||||
@@ -3,9 +3,9 @@ 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';
|
||||
import { useSupported } from '@/composables/utilities/useSupported';
|
||||
import { useIntervalFn } from '@/composables/animation/useIntervalFn';
|
||||
import type { UseIntervalFnReturn } from '@/composables/animation/useIntervalFn';
|
||||
|
||||
export interface UseVibrateOptions extends ConfigurableNavigator {
|
||||
/**
|
||||
@@ -85,7 +85,7 @@ export function useVibrate(options: UseVibrateOptions = {}): UseVibrateReturn {
|
||||
navigator = defaultNavigator,
|
||||
} = options;
|
||||
|
||||
const isSupported = useSupported(() => typeof navigator !== 'undefined' && !!navigator && 'vibrate' in navigator);
|
||||
const isSupported = useSupported(() => navigator !== undefined && !!navigator && 'vibrate' in navigator);
|
||||
|
||||
const patternRef = toRef(pattern);
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick } from 'vue';
|
||||
import { useWakeLock } from '.';
|
||||
import type { WakeLockSentinel, WakeLockType } from '.';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
interface FakeSentinel extends WakeLockSentinel {
|
||||
dispatchRelease: () => void;
|
||||
}
|
||||
|
||||
function createFakeSentinel(type: WakeLockType): FakeSentinel {
|
||||
const target = new EventTarget();
|
||||
const sentinel = target as unknown as FakeSentinel;
|
||||
sentinel.type = type;
|
||||
sentinel.released = false;
|
||||
sentinel.release = vi.fn(async () => {
|
||||
sentinel.released = true;
|
||||
});
|
||||
sentinel.dispatchRelease = () => {
|
||||
sentinel.released = true;
|
||||
target.dispatchEvent(new Event('release'));
|
||||
};
|
||||
return sentinel;
|
||||
}
|
||||
|
||||
interface FakeEnv {
|
||||
navigator: Navigator;
|
||||
request: ReturnType<typeof vi.fn>;
|
||||
sentinels: FakeSentinel[];
|
||||
}
|
||||
|
||||
function createFakeNavigator(): FakeEnv {
|
||||
const sentinels: FakeSentinel[] = [];
|
||||
const request = vi.fn(async (type: WakeLockType) => {
|
||||
const sentinel = createFakeSentinel(type);
|
||||
sentinels.push(sentinel);
|
||||
return sentinel;
|
||||
});
|
||||
const navigator = { wakeLock: { request } } as unknown as Navigator;
|
||||
return { navigator, request, sentinels };
|
||||
}
|
||||
|
||||
function createFakeDocument(state: DocumentVisibilityState = 'visible') {
|
||||
const target = new EventTarget();
|
||||
const doc = {
|
||||
visibilityState: state,
|
||||
addEventListener: target.addEventListener.bind(target),
|
||||
removeEventListener: target.removeEventListener.bind(target),
|
||||
} as unknown as Document & { visibilityState: DocumentVisibilityState };
|
||||
const setVisibility = (next: DocumentVisibilityState) => {
|
||||
doc.visibilityState = next;
|
||||
target.dispatchEvent(new Event('visibilitychange'));
|
||||
};
|
||||
return { document: doc, setVisibility };
|
||||
}
|
||||
|
||||
describe(useWakeLock, () => {
|
||||
it('reports supported when navigator.wakeLock exists', () => {
|
||||
const { navigator } = createFakeNavigator();
|
||||
const scope = effectScope();
|
||||
let api: ReturnType<typeof useWakeLock>;
|
||||
scope.run(() => {
|
||||
api = useWakeLock({ navigator });
|
||||
});
|
||||
|
||||
expect(api!.isSupported.value).toBeTruthy();
|
||||
expect(api!.isActive.value).toBeFalsy();
|
||||
expect(api!.sentinel.value).toBeNull();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reports unsupported when wakeLock is missing', () => {
|
||||
const navigator = {} as Navigator;
|
||||
const scope = effectScope();
|
||||
let api: ReturnType<typeof useWakeLock>;
|
||||
scope.run(() => {
|
||||
api = useWakeLock({ navigator });
|
||||
});
|
||||
|
||||
expect(api!.isSupported.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('requests a wake lock and becomes active', async () => {
|
||||
const { navigator, request } = createFakeNavigator();
|
||||
const { document } = createFakeDocument('visible');
|
||||
const scope = effectScope();
|
||||
let api: ReturnType<typeof useWakeLock>;
|
||||
scope.run(() => {
|
||||
api = useWakeLock({ navigator, document });
|
||||
});
|
||||
|
||||
await api!.request('screen');
|
||||
|
||||
expect(request).toHaveBeenCalledWith('screen');
|
||||
expect(api!.sentinel.value).not.toBeNull();
|
||||
expect(api!.isActive.value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('releases the wake lock and clears the sentinel', async () => {
|
||||
const { navigator, sentinels } = createFakeNavigator();
|
||||
const { document } = createFakeDocument('visible');
|
||||
const scope = effectScope();
|
||||
let api: ReturnType<typeof useWakeLock>;
|
||||
scope.run(() => {
|
||||
api = useWakeLock({ navigator, document });
|
||||
});
|
||||
|
||||
await api!.request('screen');
|
||||
await api!.release();
|
||||
|
||||
expect(sentinels[0]!.release).toHaveBeenCalledTimes(1);
|
||||
expect(api!.sentinel.value).toBeNull();
|
||||
expect(api!.isActive.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('forceRequest releases the previous lock before acquiring a new one', async () => {
|
||||
const { navigator, sentinels, request } = createFakeNavigator();
|
||||
const { document } = createFakeDocument('visible');
|
||||
const scope = effectScope();
|
||||
let api: ReturnType<typeof useWakeLock>;
|
||||
scope.run(() => {
|
||||
api = useWakeLock({ navigator, document });
|
||||
});
|
||||
|
||||
await api!.forceRequest('screen');
|
||||
await api!.forceRequest('screen');
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
expect(sentinels[0]!.release).toHaveBeenCalledTimes(1);
|
||||
expect(api!.sentinel.value).toBe(sentinels[1]);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('defers the request while hidden and re-acquires on visible', async () => {
|
||||
const { navigator, request } = createFakeNavigator();
|
||||
const { document, setVisibility } = createFakeDocument('hidden');
|
||||
const scope = effectScope();
|
||||
let api: ReturnType<typeof useWakeLock>;
|
||||
scope.run(() => {
|
||||
api = useWakeLock({ navigator, document });
|
||||
});
|
||||
|
||||
await api!.request('screen');
|
||||
|
||||
// Hidden: nothing acquired yet
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
expect(api!.sentinel.value).toBeNull();
|
||||
|
||||
setVisibility('visible');
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(request).toHaveBeenCalledWith('screen');
|
||||
expect(api!.sentinel.value).not.toBeNull();
|
||||
expect(api!.isActive.value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('re-acquires after the browser releases the lock on the next visible transition', async () => {
|
||||
const { navigator, sentinels, request } = createFakeNavigator();
|
||||
const { document, setVisibility } = createFakeDocument('visible');
|
||||
const scope = effectScope();
|
||||
let api: ReturnType<typeof useWakeLock>;
|
||||
scope.run(() => {
|
||||
api = useWakeLock({ navigator, document });
|
||||
});
|
||||
|
||||
await api!.request('screen');
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Browser auto-releases the sentinel (e.g. the tab was hidden)
|
||||
setVisibility('hidden');
|
||||
await nextTick();
|
||||
sentinels[0]!.dispatchRelease();
|
||||
await nextTick();
|
||||
|
||||
// Becoming visible again should re-acquire
|
||||
setVisibility('visible');
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('is SSR-safe with no navigator/document', async () => {
|
||||
const scope = effectScope();
|
||||
let api: ReturnType<typeof useWakeLock>;
|
||||
scope.run(() => {
|
||||
api = useWakeLock({ navigator: undefined, document: undefined });
|
||||
});
|
||||
|
||||
expect(api!.isSupported.value).toBeFalsy();
|
||||
|
||||
// Should not throw on the unsupported path
|
||||
await api!.request('screen');
|
||||
expect(api!.sentinel.value).toBeNull();
|
||||
expect(api!.isActive.value).toBeFalsy();
|
||||
await api!.release();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('releases on scope dispose', async () => {
|
||||
const { navigator, sentinels } = createFakeNavigator();
|
||||
const { document } = createFakeDocument('visible');
|
||||
const scope = effectScope();
|
||||
let api: ReturnType<typeof useWakeLock>;
|
||||
scope.run(() => {
|
||||
api = useWakeLock({ navigator, document });
|
||||
});
|
||||
|
||||
await api!.request('screen');
|
||||
scope.stop();
|
||||
await nextTick();
|
||||
|
||||
expect(sentinels[0]!.release).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user