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:
@@ -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';
|
||||
|
||||
47
web/vue/src/composables/browser/useFps/demo.vue
Normal file
47
web/vue/src/composables/browser/useFps/demo.vue
Normal 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>
|
||||
244
web/vue/src/composables/browser/useFps/index.test.ts
Normal file
244
web/vue/src/composables/browser/useFps/index.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
109
web/vue/src/composables/browser/useFps/index.ts
Normal file
109
web/vue/src/composables/browser/useFps/index.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
259
web/vue/src/composables/browser/useIntervalFn/index.test.ts
Normal file
259
web/vue/src/composables/browser/useIntervalFn/index.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
112
web/vue/src/composables/browser/useIntervalFn/index.ts
Normal file
112
web/vue/src/composables/browser/useIntervalFn/index.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
252
web/vue/src/composables/browser/useRafFn/index.test.ts
Normal file
252
web/vue/src/composables/browser/useRafFn/index.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
120
web/vue/src/composables/browser/useRafFn/index.ts
Normal file
120
web/vue/src/composables/browser/useRafFn/index.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
55
web/vue/src/composables/browser/useTabLeader/demo.vue
Normal file
55
web/vue/src/composables/browser/useTabLeader/demo.vue
Normal 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>
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './unrefElement';
|
||||
export * from './useRenderCount';
|
||||
export * from './useRenderInfo';
|
||||
export * from './useTemplateRefsList';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user