feat(vue): expand @robonen/vue composable collection
Composables, tests, category barrels, and README for @robonen/vue.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user