feat(vue): expand @robonen/vue composable collection

Composables, tests, category barrels, and README for @robonen/vue.
This commit is contained in:
2026-06-08 15:51:16 +07:00
parent 9a912f7a77
commit 59e995d0b5
369 changed files with 36554 additions and 188 deletions
@@ -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);
}