1
0
mirror of https://github.com/robonen/tools.git synced 2026-03-20 10:54:44 +00:00

feat(docs): add document generator

This commit is contained in:
2026-02-15 16:49:37 +07:00
parent a83e2bb797
commit abd6605db3
38 changed files with 9547 additions and 86 deletions

View File

@@ -1,4 +1,7 @@
export * from './useEventListener';
export * from './useFocusGuard';
export * from './useFps';
export * from './useIntervalFn';
export * from './useRafFn';
export * from './useSupported';
export * from './useTabLeader';

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import { useFps } from './index';
const { fps, min, max, isActive, reset, toggle } = useFps({ every: 10 });
</script>
<template>
<div class="space-y-4">
<div class="flex items-end gap-8">
<div>
<div class="text-4xl font-mono font-bold tabular-nums text-(--color-text)">{{ fps }}</div>
<div class="text-xs text-(--color-text-mute) mt-1">FPS</div>
</div>
<div>
<div class="text-xl font-mono tabular-nums text-(--color-text-soft)">{{ min === Infinity ? '—' : min }}</div>
<div class="text-xs text-(--color-text-mute) mt-1">Min</div>
</div>
<div>
<div class="text-xl font-mono tabular-nums text-(--color-text-soft)">{{ max || '—' }}</div>
<div class="text-xs text-(--color-text-mute) mt-1">Max</div>
</div>
</div>
<div class="h-2 rounded-full border border-(--color-border) overflow-hidden">
<div
class="h-full rounded-full transition-all duration-300"
:class="fps >= 50 ? 'bg-emerald-500' : fps >= 30 ? 'bg-amber-500' : 'bg-red-500'"
:style="{ width: `${Math.min(fps / 60 * 100, 100)}%` }"
/>
</div>
<div class="flex items-center gap-2">
<button
class="px-3 py-1.5 text-sm rounded-md border border-(--color-border) bg-(--color-bg) hover:bg-(--color-bg-mute) text-(--color-text-soft) transition-colors cursor-pointer"
@click="toggle"
>
{{ isActive ? 'Pause' : 'Resume' }}
</button>
<button
class="px-3 py-1.5 text-sm rounded-md border border-(--color-border) bg-(--color-bg) hover:bg-(--color-bg-mute) text-(--color-text-soft) transition-colors cursor-pointer"
@click="reset"
>
Reset
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,244 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { effectScope } from 'vue';
import { useFps } from '.';
let rafCallbacks: Array<(time: number) => void> = [];
let rafIdCounter = 0;
beforeEach(() => {
rafCallbacks = [];
rafIdCounter = 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) {
const cbs = [...rafCallbacks];
rafCallbacks = [];
cbs.forEach(cb => cb(time));
}
function triggerFrames(startTime: number, interval: number, count: number) {
for (let i = 0; i < count; i++) {
triggerFrame(startTime + i * interval);
}
}
describe(useFps, () => {
it('starts at 0 fps', () => {
const scope = effectScope();
let result: ReturnType<typeof useFps>;
scope.run(() => {
result = useFps();
});
expect(result!.fps.value).toBe(0);
scope.stop();
});
it('reports fps after "every" frames', () => {
const scope = effectScope();
let result: ReturnType<typeof useFps>;
scope.run(() => {
result = useFps({ every: 5 });
});
// ~60fps = 16.67ms per frame
// First frame has delta=0, skipped by useFps. Need 5 real-delta frames.
triggerFrame(100); // delta=0, skipped
triggerFrame(116.67); // delta=16.67
triggerFrame(133.33); // delta=16.66
triggerFrame(150); // delta=16.67
triggerFrame(166.67); // delta=16.67
triggerFrame(183.33); // delta=16.66 → 5 deltas collected, update
expect(result!.fps.value).toBe(60);
scope.stop();
});
it('does not update fps before collecting enough frames', () => {
const scope = effectScope();
let result: ReturnType<typeof useFps>;
scope.run(() => {
result = useFps({ every: 10 });
});
triggerFrame(100);
triggerFrame(116.67);
triggerFrame(133.33);
expect(result!.fps.value).toBe(0);
scope.stop();
});
it('tracks min and max fps', () => {
const scope = effectScope();
let result: ReturnType<typeof useFps>;
scope.run(() => {
result = useFps({ every: 3 });
});
// First batch: ~60fps (16.67ms intervals)
triggerFrame(100); // delta=0, skipped
triggerFrame(116.67); // delta=16.67
triggerFrame(133.33); // delta=16.66
triggerFrame(150); // delta=16.67 → 3 deltas, update
const firstFps = result!.fps.value;
expect(firstFps).toBe(60);
// Second batch: ~30fps (33.33ms intervals)
triggerFrame(183.33); // delta=33.33
triggerFrame(216.67); // delta=33.34
triggerFrame(250); // delta=33.33 → 3 deltas, update
const secondFps = result!.fps.value;
expect(secondFps).toBe(30);
expect(result!.max.value).toBe(60);
expect(result!.min.value).toBe(30);
scope.stop();
});
it('resets min, max, and fps', () => {
const scope = effectScope();
let result: ReturnType<typeof useFps>;
scope.run(() => {
result = useFps({ every: 3 });
});
triggerFrame(100);
triggerFrame(116.67);
triggerFrame(133.33);
triggerFrame(150);
expect(result!.fps.value).toBe(60);
result!.reset();
expect(result!.fps.value).toBe(0);
expect(result!.min.value).toBe(Infinity);
expect(result!.max.value).toBe(0);
scope.stop();
});
it('cleans up on scope dispose', () => {
const scope = effectScope();
scope.run(() => {
useFps();
});
// Should not throw on stop
scope.stop();
// No more raf callbacks should be registered after stop
triggerFrame(100);
expect(rafCallbacks).toHaveLength(0);
});
it('does nothing when window is undefined (SSR)', () => {
const scope = effectScope();
let result: ReturnType<typeof useFps>;
scope.run(() => {
result = useFps({ window: undefined as any });
});
expect(result!.fps.value).toBe(0);
scope.stop();
});
it('is active by default', () => {
const scope = effectScope();
let result: ReturnType<typeof useFps>;
scope.run(() => {
result = useFps();
});
expect(result!.isActive.value).toBeTruthy();
scope.stop();
});
it('does not start when immediate is false', () => {
const scope = effectScope();
let result: ReturnType<typeof useFps>;
scope.run(() => {
result = useFps({ immediate: false });
});
expect(result!.isActive.value).toBeFalsy();
scope.stop();
});
it('pauses and resumes fps tracking', () => {
const scope = effectScope();
let result: ReturnType<typeof useFps>;
scope.run(() => {
result = useFps({ every: 3 });
});
// Collect one batch
triggerFrame(100);
triggerFrame(116.67);
triggerFrame(133.33);
triggerFrame(150);
expect(result!.fps.value).toBe(60);
result!.pause();
expect(result!.isActive.value).toBeFalsy();
// Frames while paused should not update
triggerFrame(200);
triggerFrame(300);
triggerFrame(400);
triggerFrame(500);
expect(result!.fps.value).toBe(60);
result!.resume();
expect(result!.isActive.value).toBeTruthy();
scope.stop();
});
it('toggles fps tracking', () => {
const scope = effectScope();
let result: ReturnType<typeof useFps>;
scope.run(() => {
result = useFps();
});
expect(result!.isActive.value).toBeTruthy();
result!.toggle();
expect(result!.isActive.value).toBeFalsy();
result!.toggle();
expect(result!.isActive.value).toBeTruthy();
scope.stop();
});
});

View File

@@ -0,0 +1,109 @@
import { ref } from 'vue';
import type { Ref } from 'vue';
import type { ConfigurableWindow, ResumableActions, ResumableOptions } from '@/types';
import { useRafFn } from '@/composables/browser/useRafFn';
import type { UseRafFnCallbackArgs } from '@/composables/browser/useRafFn';
export interface UseFpsOptions extends ResumableOptions, ConfigurableWindow {
/**
* Number of frames to average over for a smoother reading.
*
* @default 10
*/
every?: number;
}
export interface UseFpsReturn extends ResumableActions {
/**
* Current frames per second (averaged over the last `every` frames)
*/
fps: Readonly<Ref<number>>;
/**
* Minimum FPS recorded since the composable was created or last reset
*/
min: Readonly<Ref<number>>;
/**
* Maximum FPS recorded since the composable was created or last reset
*/
max: Readonly<Ref<number>>;
/**
* Whether the FPS counter is currently active
*/
isActive: Readonly<Ref<boolean>>;
/**
* Reset min/max tracking
*/
reset: () => void;
}
/**
* Reactive FPS counter based on `requestAnimationFrame`.
* Reports a smoothed FPS value averaged over a configurable number of frames,
* and tracks min/max values.
*
* @param options - Configuration options
*
* @example
* ```ts
* const { fps, min, max, reset } = useFps();
* ```
*/
export function useFps(options: UseFpsOptions = {}): UseFpsReturn {
const { every = 10, ...rafOptions } = options;
const fps = ref(0);
const min = ref(Infinity);
const max = ref(0);
let deltaSum = 0;
let frameCount = 0;
function update({ delta }: UseRafFnCallbackArgs) {
if (!delta)
return;
deltaSum += delta;
frameCount++;
if (frameCount < every)
return;
const currentFps = Math.round(1000 / (deltaSum / frameCount));
fps.value = currentFps;
if (currentFps < min.value)
min.value = currentFps;
if (currentFps > max.value)
max.value = currentFps;
deltaSum = 0;
frameCount = 0;
}
function reset() {
min.value = Infinity;
max.value = 0;
fps.value = 0;
deltaSum = 0;
frameCount = 0;
}
const { isActive, pause, resume, toggle } = useRafFn(update, rafOptions);
return {
fps,
min,
max,
isActive,
reset,
pause,
resume,
toggle,
};
}

View File

@@ -0,0 +1,259 @@
import { describe, expect, it, vi, beforeEach, afterEach } 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();
});
});

View File

@@ -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,
};
}

View File

@@ -0,0 +1,252 @@
import { describe, expect, it, vi, beforeEach, afterEach } 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();
});
});

View File

@@ -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,
};
}

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import { useTabLeader } from './index';
const { isLeader, isSupported, acquire, release } = useTabLeader('docs-demo-leader');
</script>
<template>
<div class="space-y-4">
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-(--color-text-soft)">Web Locks API:</span>
<span
class="inline-flex items-center gap-1.5 text-sm font-mono px-2 py-0.5 rounded border"
:class="isSupported ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700' : 'border-red-500/30 bg-red-500/10 text-red-700'"
>
{{ isSupported ? 'Supported' : 'Not supported' }}
</span>
</div>
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-(--color-text-soft)">Leader status:</span>
<span
class="inline-flex items-center gap-1.5 text-sm font-mono px-2 py-0.5 rounded border"
:class="isLeader ? 'border-brand-500/30 bg-brand-500/10 text-brand-600' : 'border-(--color-border) bg-(--color-bg-mute) text-(--color-text-soft)'"
>
<span
class="w-2 h-2 rounded-full"
:class="isLeader ? 'bg-brand-500 animate-pulse' : 'bg-(--color-text-mute)'"
/>
{{ isLeader ? 'Leader' : 'Follower' }}
</span>
</div>
<p class="text-xs text-(--color-text-mute)">
Open this page in multiple tabs only one will be the leader.
Close the leader tab and another will take over automatically.
</p>
<div class="flex items-center gap-2 pt-2">
<button
class="px-3 py-1.5 text-sm rounded-md border border-(--color-border) bg-(--color-bg) hover:bg-(--color-bg-mute) text-(--color-text-soft) transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="!isSupported || isLeader"
@click="acquire"
>
Acquire
</button>
<button
class="px-3 py-1.5 text-sm rounded-md border border-(--color-border) bg-(--color-bg) hover:bg-(--color-bg-mute) text-(--color-text-soft) transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="!isSupported || !isLeader"
@click="release"
>
Release
</button>
</div>
</div>
</template>

View File

@@ -1,3 +1,4 @@
export * from './unrefElement';
export * from './useRenderCount';
export * from './useRenderInfo';
export * from './useTemplateRefsList';

View File

@@ -0,0 +1,141 @@
import { describe, expect, it } from 'vitest';
import { defineComponent, nextTick, ref } from 'vue';
import { mount } from '@vue/test-utils';
import { useTemplateRefsList } from '.';
describe(useTemplateRefsList, () => {
it('collects elements rendered with v-for', async () => {
const Component = defineComponent({
setup() {
const items = ref([1, 2, 3]);
const { refs, set } = useTemplateRefsList<HTMLDivElement>();
return { items, refs, set };
},
template: `<div v-for="item in items" :key="item" :ref="set">{{ item }}</div>`,
});
const wrapper = mount(Component);
await nextTick();
expect(wrapper.vm.refs).toHaveLength(3);
expect(wrapper.vm.refs[0]).toBeInstanceOf(HTMLDivElement);
expect(wrapper.vm.refs[1]).toBeInstanceOf(HTMLDivElement);
expect(wrapper.vm.refs[2]).toBeInstanceOf(HTMLDivElement);
});
it('updates refs when items are added', async () => {
const Component = defineComponent({
setup() {
const items = ref([1, 2]);
const { refs, set } = useTemplateRefsList<HTMLDivElement>();
return { items, refs, set };
},
template: `<div v-for="item in items" :key="item" :ref="set">{{ item }}</div>`,
});
const wrapper = mount(Component);
await nextTick();
expect(wrapper.vm.refs).toHaveLength(2);
wrapper.vm.items.push(3);
await nextTick();
expect(wrapper.vm.refs).toHaveLength(3);
});
it('updates refs when items are removed', async () => {
const Component = defineComponent({
setup() {
const items = ref([1, 2, 3]);
const { refs, set } = useTemplateRefsList<HTMLDivElement>();
return { items, refs, set };
},
template: `<div v-for="item in items" :key="item" :ref="set">{{ item }}</div>`,
});
const wrapper = mount(Component);
await nextTick();
expect(wrapper.vm.refs).toHaveLength(3);
wrapper.vm.items.splice(0, 1);
await nextTick();
expect(wrapper.vm.refs).toHaveLength(2);
});
it('returns empty array when no elements are rendered', async () => {
const Component = defineComponent({
setup() {
const items = ref<number[]>([]);
const { refs, set } = useTemplateRefsList<HTMLDivElement>();
return { items, refs, set };
},
template: `<div><span v-for="item in items" :key="item" :ref="set">{{ item }}</span></div>`,
});
const wrapper = mount(Component);
await nextTick();
expect(wrapper.vm.refs).toHaveLength(0);
});
it('unwraps component instances to their root elements', async () => {
const Child = defineComponent({
template: `<span class="child">child</span>`,
});
const Parent = defineComponent({
components: { Child },
setup() {
const items = ref([1, 2]);
const { refs, set } = useTemplateRefsList<HTMLSpanElement>();
return { items, refs, set };
},
template: `<div><Child v-for="item in items" :key="item" :ref="set" /></div>`,
});
const wrapper = mount(Parent);
await nextTick();
expect(wrapper.vm.refs).toHaveLength(2);
expect(wrapper.vm.refs[0]).toBeInstanceOf(HTMLSpanElement);
expect(wrapper.vm.refs[0]!.classList.contains('child')).toBe(true);
});
it('preserves element order matching v-for order', async () => {
const Component = defineComponent({
setup() {
const items = ref(['a', 'b', 'c']);
const { refs, set } = useTemplateRefsList<HTMLDivElement>();
return { items, refs, set };
},
template: `<div v-for="item in items" :key="item" :ref="set" :data-item="item">{{ item }}</div>`,
});
const wrapper = mount(Component);
await nextTick();
expect(wrapper.vm.refs[0]!.dataset.item).toBe('a');
expect(wrapper.vm.refs[1]!.dataset.item).toBe('b');
expect(wrapper.vm.refs[2]!.dataset.item).toBe('c');
});
it('handles complete list replacement', async () => {
const Component = defineComponent({
setup() {
const items = ref([1, 2, 3]);
const { refs, set } = useTemplateRefsList<HTMLDivElement>();
return { items, refs, set };
},
template: `<div v-for="item in items" :key="item" :ref="set" :data-item="item">{{ item }}</div>`,
});
const wrapper = mount(Component);
await nextTick();
expect(wrapper.vm.refs).toHaveLength(3);
wrapper.vm.items = [4, 5];
await nextTick();
expect(wrapper.vm.refs).toHaveLength(2);
expect(wrapper.vm.refs[0]!.dataset.item).toBe('4');
expect(wrapper.vm.refs[1]!.dataset.item).toBe('5');
});
});

View File

@@ -0,0 +1,72 @@
import { onBeforeUpdate, onMounted, onUpdated, readonly, shallowRef } from 'vue';
import type { DeepReadonly, ShallowRef } from 'vue';
import type { MaybeElement } from '../unrefElement';
import { unrefElement } from '../unrefElement';
export interface UseTemplateRefsListReturn<El extends Element> {
/** Reactive readonly array of collected template refs */
refs: DeepReadonly<ShallowRef<El[]>>;
/** Ref setter function — bind via `:ref="set"` in templates */
set: (el: MaybeElement) => void;
}
/**
* @name useTemplateRefsList
* @category Component
* @description Collects a dynamic list of template refs for use with `v-for`.
* Automatically clears the list before each component update and repopulates it
* with fresh element references. Handles both plain DOM elements and Vue component
* instances (unwraps `$el`).
*
* Uses a non-reactive buffer internally to collect refs during the render cycle,
* then flushes to a `shallowRef` in `onMounted`/`onUpdated` to avoid triggering
* recursive update loops.
*
* @returns {UseTemplateRefsListReturn<El>} An object with a reactive `refs` array and a `set` function
*
* @example
* const { refs, set } = useTemplateRefsList<HTMLDivElement>();
* // Template: <div v-for="item in items" :key="item.id" :ref="set" />
* // refs.value contains all rendered div elements
*
* @since 0.0.14
*/
export function useTemplateRefsList<El extends Element = Element>(): UseTemplateRefsListReturn<El> {
const refs = shallowRef<El[]>([]);
let buffer: El[] = [];
const set = (el: MaybeElement) => {
const plain = unrefElement(el);
if (plain)
buffer.push(plain as unknown as El);
};
const flush = () => {
buffer.sort(documentPositionComparator);
refs.value = buffer;
};
onBeforeUpdate(() => {
buffer = [];
});
onMounted(flush);
onUpdated(flush);
return {
refs: readonly(refs) as DeepReadonly<ShallowRef<El[]>>,
set,
};
}
function documentPositionComparator(a: Element, b: Element): number {
if (a === b) return 0;
const position = a.compareDocumentPosition(b);
if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1;
if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1;
return 0;
}