feat(vue): expand @robonen/vue composable collection
Composables, tests, category barrels, and README for @robonen/vue.
This commit is contained in:
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { isReadonly } from 'vue';
|
||||
import { useInterval } from '.';
|
||||
|
||||
describe(useInterval, () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('increments the counter on every tick', () => {
|
||||
const counter = useInterval(100);
|
||||
|
||||
expect(counter.value).toBe(0);
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(counter.value).toBe(1);
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(counter.value).toBe(3);
|
||||
});
|
||||
|
||||
it('returns a read-only counter', () => {
|
||||
const counter = useInterval(100);
|
||||
expect(isReadonly(counter)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not tick when immediate is false', () => {
|
||||
const counter = useInterval(100, { immediate: false });
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(counter.value).toBe(0);
|
||||
});
|
||||
|
||||
it('exposes controls and reset when controls: true', () => {
|
||||
const { counter, pause, reset } = useInterval(100, { controls: true });
|
||||
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(counter.value).toBe(2);
|
||||
|
||||
pause();
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(counter.value).toBe(2);
|
||||
|
||||
reset();
|
||||
expect(counter.value).toBe(0);
|
||||
});
|
||||
|
||||
it('exposes a read-only counter in controls mode', () => {
|
||||
const { counter } = useInterval(100, { controls: true });
|
||||
expect(isReadonly(counter)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('exposes isActive reflecting the running state', () => {
|
||||
const { isActive, pause, resume } = useInterval(100, { controls: true });
|
||||
|
||||
expect(isActive.value).toBeTruthy();
|
||||
|
||||
pause();
|
||||
expect(isActive.value).toBeFalsy();
|
||||
|
||||
resume();
|
||||
expect(isActive.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('resumes after a pause and keeps counting from where it left off', () => {
|
||||
const { counter, pause, resume } = useInterval(100, { controls: true });
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(counter.value).toBe(1);
|
||||
|
||||
pause();
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(counter.value).toBe(1);
|
||||
|
||||
resume();
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(counter.value).toBe(3);
|
||||
});
|
||||
|
||||
it('toggle flips the active state', () => {
|
||||
const { isActive, toggle } = useInterval(100, { controls: true });
|
||||
|
||||
expect(isActive.value).toBeTruthy();
|
||||
toggle();
|
||||
expect(isActive.value).toBeFalsy();
|
||||
toggle();
|
||||
expect(isActive.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('invokes the callback with the incremented counter value', () => {
|
||||
const callback = vi.fn();
|
||||
useInterval(100, { callback });
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(callback).toHaveBeenCalledWith(1);
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(callback).toHaveBeenLastCalledWith(2);
|
||||
});
|
||||
|
||||
it('reset keeps the interval running', () => {
|
||||
const { counter, reset } = useInterval(100, { controls: true });
|
||||
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(counter.value).toBe(2);
|
||||
|
||||
reset();
|
||||
expect(counter.value).toBe(0);
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(counter.value).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { shallowReadonly, shallowRef } 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 UseIntervalOptions<Controls extends boolean> {
|
||||
/**
|
||||
* Expose pause/resume controls alongside the counter
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
controls?: Controls;
|
||||
|
||||
/**
|
||||
* Start the interval immediately
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
immediate?: boolean;
|
||||
|
||||
/**
|
||||
* Callback invoked on every tick with the current counter value
|
||||
*/
|
||||
callback?: (count: number) => void;
|
||||
}
|
||||
|
||||
export interface UseIntervalControls extends ResumableActions {
|
||||
/**
|
||||
* The current counter value (read-only; use `reset` to set it back to 0)
|
||||
*/
|
||||
counter: Readonly<ShallowRef<number>>;
|
||||
|
||||
/**
|
||||
* Whether the interval is currently active
|
||||
*/
|
||||
isActive: UseIntervalFnReturn['isActive'];
|
||||
|
||||
/**
|
||||
* Reset the counter back to 0
|
||||
*/
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export type UseIntervalReturn = Readonly<ShallowRef<number>> | UseIntervalControls;
|
||||
|
||||
/**
|
||||
* @name useInterval
|
||||
* @category Animation
|
||||
* @description Reactive counter that increments on every interval tick.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<number>} [interval=1000] Interval in milliseconds (can be reactive)
|
||||
* @param {UseIntervalOptions} [options={}] Options
|
||||
* @returns {Readonly<ShallowRef<number>> | UseIntervalControls} The read-only counter, or controls when `controls: true`
|
||||
*
|
||||
* @example
|
||||
* const counter = useInterval(1000);
|
||||
*
|
||||
* @example
|
||||
* const { counter, isActive, pause, resume, reset } = useInterval(1000, { controls: true });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useInterval(interval?: MaybeRefOrGetter<number>, options?: UseIntervalOptions<false>): Readonly<ShallowRef<number>>;
|
||||
export function useInterval(interval: MaybeRefOrGetter<number>, options: UseIntervalOptions<true>): UseIntervalControls;
|
||||
export function useInterval(
|
||||
interval: MaybeRefOrGetter<number> = 1000,
|
||||
options: UseIntervalOptions<boolean> = {},
|
||||
): UseIntervalReturn {
|
||||
const {
|
||||
controls = false,
|
||||
immediate = true,
|
||||
callback,
|
||||
} = options;
|
||||
|
||||
const counter = shallowRef(0);
|
||||
const reset = (): void => {
|
||||
counter.value = 0;
|
||||
};
|
||||
|
||||
const update = callback
|
||||
? () => callback(++counter.value)
|
||||
: () => void counter.value++;
|
||||
|
||||
const intervalControls = useIntervalFn(update, interval, { immediate });
|
||||
|
||||
if (controls) {
|
||||
return {
|
||||
counter: shallowReadonly(counter),
|
||||
reset,
|
||||
...intervalControls,
|
||||
};
|
||||
}
|
||||
|
||||
return shallowReadonly(counter);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { defineComponent, effectScope, nextTick, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { useIntervalFn } from '.';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const ComponentStub = defineComponent({
|
||||
props: {
|
||||
callback: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
interval: {
|
||||
type: Number,
|
||||
default: 1000,
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const result = useIntervalFn(props.callback as () => void, props.interval, props.options);
|
||||
return { ...result };
|
||||
},
|
||||
template: '<div>{{ isActive }}</div>',
|
||||
});
|
||||
|
||||
describe(useIntervalFn, () => {
|
||||
it('starts immediately by default', () => {
|
||||
const callback = vi.fn();
|
||||
const wrapper = mount(ComponentStub, {
|
||||
props: { callback },
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toBe('true');
|
||||
});
|
||||
|
||||
it('does not start when immediate is false', () => {
|
||||
const callback = vi.fn();
|
||||
mount(ComponentStub, {
|
||||
props: {
|
||||
callback,
|
||||
options: { immediate: false },
|
||||
},
|
||||
});
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls callback on each interval', () => {
|
||||
const callback = vi.fn();
|
||||
mount(ComponentStub, {
|
||||
props: { callback, interval: 500 },
|
||||
});
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
|
||||
vi.advanceTimersByTime(1500);
|
||||
expect(callback).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('calls callback immediately when immediateCallback is true', () => {
|
||||
const callback = vi.fn();
|
||||
mount(ComponentStub, {
|
||||
props: {
|
||||
callback,
|
||||
interval: 1000,
|
||||
options: { immediateCallback: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('pauses and resumes', async () => {
|
||||
const callback = vi.fn();
|
||||
const wrapper = mount(ComponentStub, {
|
||||
props: { callback, interval: 100 },
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(callback).toHaveBeenCalledTimes(3);
|
||||
|
||||
wrapper.vm.pause();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).toBe('false');
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(callback).toHaveBeenCalledTimes(3);
|
||||
|
||||
wrapper.vm.resume();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).toBe('true');
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(callback).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('toggles the interval', async () => {
|
||||
const callback = vi.fn();
|
||||
const wrapper = mount(ComponentStub, {
|
||||
props: { callback },
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toBe('true');
|
||||
|
||||
wrapper.vm.toggle();
|
||||
await nextTick();
|
||||
expect(wrapper.text()).toBe('false');
|
||||
|
||||
wrapper.vm.toggle();
|
||||
await nextTick();
|
||||
expect(wrapper.text()).toBe('true');
|
||||
});
|
||||
|
||||
it('supports reactive interval', async () => {
|
||||
const callback = vi.fn();
|
||||
const interval = ref(1000);
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
useIntervalFn(callback, interval);
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Change interval to 200ms — watcher triggers async
|
||||
interval.value = 200;
|
||||
await nextTick();
|
||||
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(callback).toHaveBeenCalledTimes(3);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does not fire with interval <= 0', () => {
|
||||
const callback = vi.fn();
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
const { isActive } = useIntervalFn(callback, 0);
|
||||
expect(isActive.value).toBeFalsy();
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('cleans up on scope dispose', () => {
|
||||
const callback = vi.fn();
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
useIntervalFn(callback, 100);
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(callback).toHaveBeenCalledTimes(3);
|
||||
|
||||
scope.stop();
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(callback).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('cleans up on component unmount', () => {
|
||||
const callback = vi.fn();
|
||||
const wrapper = mount(ComponentStub, {
|
||||
props: { callback, interval: 100 },
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(callback).toHaveBeenCalledTimes(3);
|
||||
|
||||
wrapper.unmount();
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(callback).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('resume is idempotent when already active', () => {
|
||||
const callback = vi.fn();
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useIntervalFn>;
|
||||
|
||||
scope.run(() => {
|
||||
result = useIntervalFn(callback, 100);
|
||||
});
|
||||
|
||||
expect(result!.isActive.value).toBeTruthy();
|
||||
result!.resume();
|
||||
expect(result!.isActive.value).toBeTruthy();
|
||||
|
||||
// Should still tick normally — no double interval
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('pause is idempotent when already paused', () => {
|
||||
const callback = vi.fn();
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useIntervalFn>;
|
||||
|
||||
scope.run(() => {
|
||||
result = useIntervalFn(callback, 100, { immediate: false });
|
||||
});
|
||||
|
||||
expect(result!.isActive.value).toBeFalsy();
|
||||
result!.pause();
|
||||
expect(result!.isActive.value).toBeFalsy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('uses default interval of 1000ms', () => {
|
||||
const callback = vi.fn();
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
useIntervalFn(callback);
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(999);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { readonly, ref, toValue, watch } from 'vue';
|
||||
import type { MaybeRefOrGetter, Ref } from 'vue';
|
||||
import type { ResumableActions, ResumableOptions } from '@/types';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
|
||||
export interface UseIntervalFnOptions extends ResumableOptions {
|
||||
/**
|
||||
* Whether to invoke the callback immediately on start.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
immediateCallback?: boolean;
|
||||
}
|
||||
|
||||
export interface UseIntervalFnReturn extends ResumableActions {
|
||||
/**
|
||||
* Whether the interval is currently active
|
||||
*/
|
||||
isActive: Readonly<Ref<boolean>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a function on every interval. Supports reactive interval duration,
|
||||
* pause/resume, and automatic cleanup on scope dispose.
|
||||
*
|
||||
* @param callback - Function to call on every interval tick
|
||||
* @param interval - Interval duration in milliseconds (can be reactive)
|
||||
* @param options - Configuration options
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const { pause, resume, isActive } = useIntervalFn(() => {
|
||||
* console.log('tick');
|
||||
* }, 1000);
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Reactive interval
|
||||
* const delay = ref(1000);
|
||||
* useIntervalFn(() => console.log('tick'), delay);
|
||||
* delay.value = 500; // interval restarts with new duration
|
||||
* ```
|
||||
*/
|
||||
export function useIntervalFn(
|
||||
callback: () => void,
|
||||
interval: MaybeRefOrGetter<number> = 1000,
|
||||
options: UseIntervalFnOptions = {},
|
||||
): UseIntervalFnReturn {
|
||||
const {
|
||||
immediate = true,
|
||||
immediateCallback = false,
|
||||
} = options;
|
||||
|
||||
const isActive = ref(false);
|
||||
|
||||
let timerId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function clean() {
|
||||
if (timerId !== null) {
|
||||
clearInterval(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function resume() {
|
||||
const ms = toValue(interval);
|
||||
|
||||
if (ms <= 0)
|
||||
return;
|
||||
|
||||
isActive.value = true;
|
||||
|
||||
if (immediateCallback)
|
||||
callback();
|
||||
|
||||
clean();
|
||||
timerId = setInterval(callback, ms);
|
||||
}
|
||||
|
||||
function pause() {
|
||||
isActive.value = false;
|
||||
clean();
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (isActive.value)
|
||||
pause();
|
||||
else
|
||||
resume();
|
||||
}
|
||||
|
||||
// Re-start when interval changes reactively
|
||||
watch(() => toValue(interval), () => {
|
||||
if (isActive.value) {
|
||||
clean();
|
||||
resume();
|
||||
}
|
||||
});
|
||||
|
||||
if (immediate)
|
||||
resume();
|
||||
|
||||
tryOnScopeDispose(pause);
|
||||
|
||||
return {
|
||||
isActive: readonly(isActive),
|
||||
pause,
|
||||
resume,
|
||||
toggle,
|
||||
};
|
||||
}
|
||||
@@ -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,252 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { defineComponent, effectScope, nextTick } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { useRafFn } from '.';
|
||||
|
||||
let rafCallbacks: Array<(time: number) => void> = [];
|
||||
let rafIdCounter = 0;
|
||||
let currentTime = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
rafCallbacks = [];
|
||||
rafIdCounter = 0;
|
||||
currentTime = 0;
|
||||
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: (time: number) => void) => {
|
||||
const id = ++rafIdCounter;
|
||||
rafCallbacks.push(cb);
|
||||
return id;
|
||||
});
|
||||
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function triggerFrame(time: number) {
|
||||
currentTime = time;
|
||||
const cbs = [...rafCallbacks];
|
||||
rafCallbacks = [];
|
||||
cbs.forEach(cb => cb(currentTime));
|
||||
}
|
||||
|
||||
const ComponentStub = defineComponent({
|
||||
props: {
|
||||
callback: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const result = useRafFn(props.callback as any, props.options);
|
||||
return { ...result };
|
||||
},
|
||||
template: '<div>{{ isActive }}</div>',
|
||||
});
|
||||
|
||||
describe(useRafFn, () => {
|
||||
it('starts immediately by default', () => {
|
||||
const callback = vi.fn();
|
||||
const wrapper = mount(ComponentStub, {
|
||||
props: { callback },
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toBe('true');
|
||||
});
|
||||
|
||||
it('does not start when immediate is false', () => {
|
||||
const callback = vi.fn();
|
||||
const wrapper = mount(ComponentStub, {
|
||||
props: {
|
||||
callback,
|
||||
options: { immediate: false },
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toBe('false');
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls the callback on animation frame with delta and timestamp', () => {
|
||||
const callback = vi.fn();
|
||||
mount(ComponentStub, {
|
||||
props: { callback },
|
||||
});
|
||||
|
||||
triggerFrame(100);
|
||||
expect(callback).toHaveBeenCalledWith({ delta: 0, timestamp: 100 });
|
||||
});
|
||||
|
||||
it('provides correct delta between frames', () => {
|
||||
const callback = vi.fn();
|
||||
mount(ComponentStub, {
|
||||
props: { callback },
|
||||
});
|
||||
|
||||
triggerFrame(100);
|
||||
triggerFrame(116.67);
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
expect(callback.mock.calls[1]![0]!.delta).toBeCloseTo(16.67, 1);
|
||||
});
|
||||
|
||||
it('pauses and resumes the loop', async () => {
|
||||
const callback = vi.fn();
|
||||
const wrapper = mount(ComponentStub, {
|
||||
props: { callback },
|
||||
});
|
||||
|
||||
triggerFrame(100);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
wrapper.vm.pause();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).toBe('false');
|
||||
triggerFrame(200);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
wrapper.vm.resume();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).toBe('true');
|
||||
triggerFrame(300);
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('resets delta after resume', () => {
|
||||
const callback = vi.fn();
|
||||
const wrapper = mount(ComponentStub, {
|
||||
props: { callback },
|
||||
});
|
||||
|
||||
triggerFrame(100);
|
||||
wrapper.vm.pause();
|
||||
|
||||
wrapper.vm.resume();
|
||||
triggerFrame(500);
|
||||
|
||||
// After resume, first frame delta resets to 0
|
||||
const lastCall = callback.mock.calls[callback.mock.calls.length - 1]![0]!;
|
||||
expect(lastCall.delta).toBe(0);
|
||||
expect(lastCall.timestamp).toBe(500);
|
||||
});
|
||||
|
||||
it('toggles the loop', async () => {
|
||||
const callback = vi.fn();
|
||||
const wrapper = mount(ComponentStub, {
|
||||
props: { callback },
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toBe('true');
|
||||
|
||||
wrapper.vm.toggle();
|
||||
await nextTick();
|
||||
expect(wrapper.text()).toBe('false');
|
||||
|
||||
wrapper.vm.toggle();
|
||||
await nextTick();
|
||||
expect(wrapper.text()).toBe('true');
|
||||
});
|
||||
|
||||
it('limits frame rate with fpsLimit', () => {
|
||||
const callback = vi.fn();
|
||||
mount(ComponentStub, {
|
||||
props: {
|
||||
callback,
|
||||
options: { fpsLimit: 30 },
|
||||
},
|
||||
});
|
||||
|
||||
// First frame always fires (delta is 0)
|
||||
triggerFrame(100);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
// 30fps = ~33.33ms per frame — too soon, skipped
|
||||
triggerFrame(110);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Enough time passed (~40ms > 33.33ms)
|
||||
triggerFrame(140);
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('cleans up on scope dispose', () => {
|
||||
const callback = vi.fn();
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
useRafFn(callback);
|
||||
});
|
||||
|
||||
triggerFrame(100);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
scope.stop();
|
||||
triggerFrame(200);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('cleans up on component unmount', () => {
|
||||
const callback = vi.fn();
|
||||
const wrapper = mount(ComponentStub, {
|
||||
props: { callback },
|
||||
});
|
||||
|
||||
triggerFrame(100);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
wrapper.unmount();
|
||||
triggerFrame(200);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does nothing when window is undefined (SSR)', () => {
|
||||
const callback = vi.fn();
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
const { isActive } = useRafFn(callback, { window: undefined as any });
|
||||
expect(isActive.value).toBeFalsy();
|
||||
});
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('resume is idempotent when already active', () => {
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useRafFn>;
|
||||
|
||||
scope.run(() => {
|
||||
result = useRafFn(vi.fn());
|
||||
});
|
||||
|
||||
expect(result!.isActive.value).toBeTruthy();
|
||||
result!.resume();
|
||||
expect(result!.isActive.value).toBeTruthy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('pause is idempotent when already paused', () => {
|
||||
const scope = effectScope();
|
||||
let result: ReturnType<typeof useRafFn>;
|
||||
|
||||
scope.run(() => {
|
||||
result = useRafFn(vi.fn(), { immediate: false });
|
||||
});
|
||||
|
||||
expect(result!.isActive.value).toBeFalsy();
|
||||
result!.pause();
|
||||
expect(result!.isActive.value).toBeFalsy();
|
||||
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { readonly, ref } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow, ResumableActions, ResumableOptions } from '@/types';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
|
||||
export interface UseRafFnCallbackArgs {
|
||||
/**
|
||||
* Time elapsed since the last frame in milliseconds
|
||||
*/
|
||||
delta: number;
|
||||
|
||||
/**
|
||||
* `DOMHighResTimeStamp` passed by `requestAnimationFrame`
|
||||
*/
|
||||
timestamp: DOMHighResTimeStamp;
|
||||
}
|
||||
|
||||
export interface UseRafFnOptions extends ResumableOptions, ConfigurableWindow {
|
||||
/**
|
||||
* Maximum frames per second. Set to `0` or `undefined` to disable the limit.
|
||||
*
|
||||
* @default undefined
|
||||
*/
|
||||
fpsLimit?: number;
|
||||
}
|
||||
|
||||
export interface UseRafFnReturn extends ResumableActions {
|
||||
/**
|
||||
* Whether the RAF loop is currently active
|
||||
*/
|
||||
isActive: Readonly<Ref<boolean>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a function on every `requestAnimationFrame` with delta time tracking.
|
||||
* Automatically cleans up when the component scope is disposed.
|
||||
*
|
||||
* @param callback - Function to call on every animation frame
|
||||
* @param options - Configuration options
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const { pause, resume, isActive } = useRafFn(({ delta, timestamp }) => {
|
||||
* console.log(`${delta}ms since last frame`);
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function useRafFn(
|
||||
callback: (args: UseRafFnCallbackArgs) => void,
|
||||
options: UseRafFnOptions = {},
|
||||
): UseRafFnReturn {
|
||||
const {
|
||||
immediate = true,
|
||||
fpsLimit,
|
||||
} = options;
|
||||
|
||||
const window = 'window' in options ? options.window : defaultWindow;
|
||||
|
||||
const isActive = ref(false);
|
||||
const intervalLimit = fpsLimit ? 1000 / fpsLimit : null;
|
||||
|
||||
let previousFrameTimestamp = 0;
|
||||
let rafId: number | null = null;
|
||||
|
||||
function loop(timestamp: DOMHighResTimeStamp) {
|
||||
if (!isActive.value || !window)
|
||||
return;
|
||||
|
||||
if (!previousFrameTimestamp)
|
||||
previousFrameTimestamp = timestamp;
|
||||
|
||||
const delta = timestamp - previousFrameTimestamp;
|
||||
|
||||
if (intervalLimit && delta && delta < intervalLimit) {
|
||||
rafId = window.requestAnimationFrame(loop);
|
||||
return;
|
||||
}
|
||||
|
||||
previousFrameTimestamp = timestamp;
|
||||
callback({ delta, timestamp });
|
||||
rafId = window.requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
function resume() {
|
||||
if (!isActive.value && window) {
|
||||
isActive.value = true;
|
||||
previousFrameTimestamp = 0;
|
||||
rafId = window.requestAnimationFrame(loop);
|
||||
}
|
||||
}
|
||||
|
||||
function pause() {
|
||||
isActive.value = false;
|
||||
|
||||
if (rafId !== null && window) {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (isActive.value)
|
||||
pause();
|
||||
else
|
||||
resume();
|
||||
}
|
||||
|
||||
if (immediate)
|
||||
resume();
|
||||
|
||||
tryOnScopeDispose(pause);
|
||||
|
||||
return {
|
||||
isActive: readonly(isActive),
|
||||
pause,
|
||||
resume,
|
||||
toggle,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, ref } from 'vue';
|
||||
import { useTimeoutFn } from '.';
|
||||
|
||||
describe(useTimeoutFn, () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('calls the callback after the interval', () => {
|
||||
const cb = vi.fn();
|
||||
const { isPending } = useTimeoutFn(cb, 100);
|
||||
|
||||
expect(isPending.value).toBeTruthy();
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(cb).toHaveBeenCalledOnce();
|
||||
expect(isPending.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('does not start when immediate is false', () => {
|
||||
const cb = vi.fn();
|
||||
const { isPending } = useTimeoutFn(cb, 100, { immediate: false });
|
||||
|
||||
expect(isPending.value).toBeFalsy();
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('start forwards arguments to the callback', () => {
|
||||
const cb = vi.fn();
|
||||
const { start } = useTimeoutFn(cb, 100, { immediate: false });
|
||||
|
||||
start('x', 1);
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(cb).toHaveBeenCalledWith('x', 1);
|
||||
});
|
||||
|
||||
it('restarting before the timeout fires only invokes the callback once with the latest args', () => {
|
||||
const cb = vi.fn();
|
||||
const { start } = useTimeoutFn(cb, 100, { immediate: false });
|
||||
|
||||
start('first');
|
||||
vi.advanceTimersByTime(50);
|
||||
start('second');
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(cb).toHaveBeenCalledOnce();
|
||||
expect(cb).toHaveBeenCalledWith('second');
|
||||
});
|
||||
|
||||
it('reads the interval reactively each time start runs', () => {
|
||||
const cb = vi.fn();
|
||||
const delay = ref(100);
|
||||
const { start } = useTimeoutFn(cb, delay, { immediate: false });
|
||||
|
||||
start();
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(cb).toHaveBeenCalledOnce();
|
||||
|
||||
delay.value = 500;
|
||||
start();
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(cb).toHaveBeenCalledOnce();
|
||||
vi.advanceTimersByTime(400);
|
||||
expect(cb).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
describe('immediateCallback', () => {
|
||||
it('invokes the callback synchronously on start and again after the delay', () => {
|
||||
const cb = vi.fn();
|
||||
const { start } = useTimeoutFn(cb, 100, {
|
||||
immediate: false,
|
||||
immediateCallback: true,
|
||||
});
|
||||
|
||||
start('a');
|
||||
expect(cb).toHaveBeenCalledOnce();
|
||||
expect(cb).toHaveBeenCalledWith('a');
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(cb).toHaveBeenCalledTimes(2);
|
||||
expect(cb).toHaveBeenLastCalledWith('a');
|
||||
});
|
||||
|
||||
it('fires synchronously during immediate auto-start', () => {
|
||||
const cb = vi.fn();
|
||||
useTimeoutFn(cb, 100, { immediateCallback: true });
|
||||
|
||||
expect(cb).toHaveBeenCalledOnce();
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(cb).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('stop cancels a pending timeout', () => {
|
||||
const cb = vi.fn();
|
||||
const { stop, isPending } = useTimeoutFn(cb, 100);
|
||||
|
||||
stop();
|
||||
expect(isPending.value).toBeFalsy();
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cleans up on scope dispose', () => {
|
||||
const cb = vi.fn();
|
||||
const scope = effectScope();
|
||||
scope.run(() => useTimeoutFn(cb, 100));
|
||||
|
||||
scope.stop();
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { readonly, ref, toValue } from 'vue';
|
||||
import type { MaybeRefOrGetter, Ref } from 'vue';
|
||||
import type { AnyFunction } from '@robonen/stdlib';
|
||||
import { isClient } from '@robonen/platform/multi';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
|
||||
export interface UseTimeoutFnOptions {
|
||||
/**
|
||||
* Start the timeout immediately when the composable is created
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
immediate?: boolean;
|
||||
|
||||
/**
|
||||
* Invoke the callback synchronously the moment `start` is called,
|
||||
* in addition to the scheduled invocation after the delay elapses
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
immediateCallback?: boolean;
|
||||
}
|
||||
|
||||
export interface UseTimeoutFnReturn<Args extends any[]> {
|
||||
/**
|
||||
* Whether the timeout is currently pending
|
||||
*/
|
||||
isPending: Readonly<Ref<boolean>>;
|
||||
|
||||
/**
|
||||
* Start (or restart) the timeout
|
||||
*/
|
||||
start: (...args: Args) => void;
|
||||
|
||||
/**
|
||||
* Cancel the pending timeout
|
||||
*/
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useTimeoutFn
|
||||
* @category 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.
|
||||
*
|
||||
* @param {T} cb The function to call after the timeout
|
||||
* @param {MaybeRefOrGetter<number>} interval Delay in milliseconds (resolved each time `start` runs, can be reactive)
|
||||
* @param {UseTimeoutFnOptions} [options={}] Options
|
||||
* @returns {UseTimeoutFnReturn} Timeout controls
|
||||
*
|
||||
* @example
|
||||
* const { isPending, start, stop } = useTimeoutFn(() => {
|
||||
* console.log('fired');
|
||||
* }, 1000);
|
||||
*
|
||||
* @example
|
||||
* // Fire once now and again after the delay
|
||||
* useTimeoutFn(refresh, 5000, { immediateCallback: true });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useTimeoutFn<T extends AnyFunction>(
|
||||
cb: T,
|
||||
interval: MaybeRefOrGetter<number>,
|
||||
options: UseTimeoutFnOptions = {},
|
||||
): UseTimeoutFnReturn<Parameters<T>> {
|
||||
const {
|
||||
immediate = true,
|
||||
immediateCallback = false,
|
||||
} = options;
|
||||
|
||||
const isPending = ref(false);
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function clear() {
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
isPending.value = false;
|
||||
clear();
|
||||
}
|
||||
|
||||
function start(...args: Parameters<T>) {
|
||||
if (immediateCallback)
|
||||
cb(...args);
|
||||
|
||||
clear();
|
||||
isPending.value = true;
|
||||
|
||||
timer = setTimeout(() => {
|
||||
isPending.value = false;
|
||||
timer = null;
|
||||
cb(...args);
|
||||
}, toValue(interval));
|
||||
}
|
||||
|
||||
if (immediate) {
|
||||
isPending.value = true;
|
||||
|
||||
if (isClient)
|
||||
start(...([] as unknown as Parameters<T>));
|
||||
}
|
||||
|
||||
tryOnScopeDispose(stop);
|
||||
|
||||
return {
|
||||
isPending: readonly(isPending),
|
||||
start,
|
||||
stop,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { useTimestamp } from '.';
|
||||
|
||||
describe(useTimestamp, () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1000);
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('returns the current timestamp', () => {
|
||||
const ts = useTimestamp({ interval: 100 });
|
||||
expect(ts.value).toBe(1000);
|
||||
});
|
||||
|
||||
it('applies the offset', () => {
|
||||
const ts = useTimestamp({ interval: 100, offset: 500 });
|
||||
expect(ts.value).toBe(1500);
|
||||
});
|
||||
|
||||
it('updates on the interval', () => {
|
||||
const ts = useTimestamp({ interval: 100 });
|
||||
|
||||
// advanceTimersByTime also advances the mocked clock, so the tick fires at 1100
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(ts.value).toBe(1100);
|
||||
});
|
||||
|
||||
it('exposes controls when controls: true', () => {
|
||||
const { timestamp, pause } = useTimestamp({ controls: true, interval: 100 });
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(timestamp.value).toBe(1100);
|
||||
|
||||
pause();
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(timestamp.value).toBe(1100);
|
||||
});
|
||||
|
||||
it('exposes isActive in the controls and reflects pause/resume', () => {
|
||||
const { isActive, pause, resume } = useTimestamp({ controls: true, interval: 100 });
|
||||
|
||||
expect(isActive.value).toBeTruthy();
|
||||
|
||||
pause();
|
||||
expect(isActive.value).toBeFalsy();
|
||||
|
||||
resume();
|
||||
expect(isActive.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not start updating when immediate is false', () => {
|
||||
const { timestamp, isActive } = useTimestamp({ controls: true, interval: 100, immediate: false });
|
||||
|
||||
expect(isActive.value).toBeFalsy();
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(timestamp.value).toBe(1000);
|
||||
});
|
||||
|
||||
it('invokes the callback on every update with the current timestamp', () => {
|
||||
const callback = vi.fn();
|
||||
useTimestamp({ interval: 100, callback });
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(callback).toHaveBeenLastCalledWith(1100);
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
expect(callback).toHaveBeenLastCalledWith(1200);
|
||||
});
|
||||
|
||||
it('supports a reactive offset that recomputes on the next update', () => {
|
||||
const offset = ref(0);
|
||||
const ts = useTimestamp({ interval: 100, offset });
|
||||
|
||||
expect(ts.value).toBe(1000);
|
||||
|
||||
offset.value = 500;
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(ts.value).toBe(1600);
|
||||
});
|
||||
|
||||
it('supports a getter offset', () => {
|
||||
let extra = 0;
|
||||
const ts = useTimestamp({ interval: 100, offset: () => extra });
|
||||
|
||||
expect(ts.value).toBe(1000);
|
||||
|
||||
extra = 250;
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(ts.value).toBe(1350);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { shallowRef, toValue } from 'vue';
|
||||
import type { MaybeRefOrGetter, Ref } from 'vue';
|
||||
import { timestamp } from '@robonen/stdlib';
|
||||
import type { ResumableActions } from '@/types';
|
||||
import { useRafFn } from '@/composables/animation/useRafFn';
|
||||
import { useIntervalFn } from '@/composables/animation/useIntervalFn';
|
||||
|
||||
export interface UseTimestampOptions<Controls extends boolean> {
|
||||
/**
|
||||
* Expose pause/resume controls alongside the timestamp
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
controls?: Controls;
|
||||
|
||||
/**
|
||||
* Offset added to the timestamp in milliseconds. Accepts a reactive value
|
||||
* (ref or getter); the timestamp recomputes with the latest offset on the
|
||||
* next update.
|
||||
*
|
||||
* @default 0
|
||||
*/
|
||||
offset?: MaybeRefOrGetter<number>;
|
||||
|
||||
/**
|
||||
* Start updating immediately
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
immediate?: boolean;
|
||||
|
||||
/**
|
||||
* Update strategy. `'requestAnimationFrame'` updates every frame; a number
|
||||
* updates on a fixed interval (ms).
|
||||
*
|
||||
* @default 'requestAnimationFrame'
|
||||
*/
|
||||
interval?: 'requestAnimationFrame' | number;
|
||||
|
||||
/**
|
||||
* Callback invoked on every update with the current timestamp
|
||||
*/
|
||||
callback?: (timestamp: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause/resume controls returned when `controls: true`.
|
||||
*/
|
||||
export interface UseTimestampControls extends ResumableActions {
|
||||
/**
|
||||
* The reactive timestamp
|
||||
*/
|
||||
timestamp: Ref<number>;
|
||||
|
||||
/**
|
||||
* Whether the updater (RAF loop or interval) is currently active
|
||||
*/
|
||||
isActive: Readonly<Ref<boolean>>;
|
||||
}
|
||||
|
||||
export type UseTimestampReturn<Controls extends boolean> = Controls extends true
|
||||
? UseTimestampControls
|
||||
: Ref<number>;
|
||||
|
||||
/**
|
||||
* @name useTimestamp
|
||||
* @category Animation
|
||||
* @description Reactive current timestamp, updated via `requestAnimationFrame`
|
||||
* or a fixed interval.
|
||||
*
|
||||
* @param {UseTimestampOptions} [options={}] Options
|
||||
* @returns {Ref<number> | UseTimestampControls} The timestamp, or controls when `controls: true`
|
||||
*
|
||||
* @example
|
||||
* const now = useTimestamp();
|
||||
*
|
||||
* @example
|
||||
* const { timestamp, pause, resume, isActive } = useTimestamp({ controls: true, interval: 1000 });
|
||||
*
|
||||
* @example
|
||||
* // Reactive offset
|
||||
* const offset = ref(0);
|
||||
* const now = useTimestamp({ offset });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useTimestamp(options?: UseTimestampOptions<false>): Ref<number>;
|
||||
export function useTimestamp(options: UseTimestampOptions<true>): UseTimestampControls;
|
||||
export function useTimestamp(
|
||||
options: UseTimestampOptions<boolean> = {},
|
||||
): Ref<number> | UseTimestampControls {
|
||||
const {
|
||||
controls = false,
|
||||
offset = 0,
|
||||
immediate = true,
|
||||
interval = 'requestAnimationFrame',
|
||||
callback,
|
||||
} = options;
|
||||
|
||||
const ts = shallowRef(timestamp() + toValue(offset));
|
||||
|
||||
const update = callback
|
||||
? () => {
|
||||
ts.value = timestamp() + toValue(offset);
|
||||
callback(ts.value);
|
||||
}
|
||||
: () => {
|
||||
ts.value = timestamp() + toValue(offset);
|
||||
};
|
||||
|
||||
const resumableControls = interval === 'requestAnimationFrame'
|
||||
? useRafFn(update, { immediate })
|
||||
: useIntervalFn(update, interval, { immediate });
|
||||
|
||||
if (controls) {
|
||||
return {
|
||||
timestamp: ts,
|
||||
...resumableControls,
|
||||
};
|
||||
}
|
||||
|
||||
return ts;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user