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

refactor: change separate tools by category

This commit is contained in:
2025-05-19 17:43:42 +07:00
parent d55737df2f
commit 78fb4da82a
158 changed files with 32 additions and 24 deletions

View File

@@ -0,0 +1,17 @@
export * from './tryOnBeforeMount';
export * from './tryOnMounted';
export * from './tryOnScopeDispose';
export * from './useAppSharedState';
export * from './useCached';
export * from './useClamp';
export * from './useContextFactory';
export * from './useCounter';
export * from './useFocusGuard';
export * from './useInjectionStore';
export * from './useLastChanged';
export * from './useMounted';
export * from './useOffsetPagination';
export * from './useRenderCount';
export * from './useRenderInfo';
export * from './useSupported';
export * from './useSyncRefs';

View File

@@ -0,0 +1,45 @@
import { onBeforeMount, nextTick, type ComponentInternalInstance } from 'vue';
import { getLifeCycleTarger } from '../..';
import type { VoidFunction } from '@robonen/stdlib';
// TODO: test
export interface TryOnBeforeMountOptions {
sync?: boolean;
target?: ComponentInternalInstance;
}
/**
* @name tryOnBeforeMount
* @category Components
* @description Call onBeforeMount if it's inside a component lifecycle hook, otherwise just calls it
*
* @param {VoidFunction} fn - The function to run on before mount.
* @param {TryOnBeforeMountOptions} options - The options for the function.
* @param {boolean} [options.sync=true] - If true, the function will run synchronously, otherwise it will run asynchronously.
* @param {ComponentInternalInstance} [options.target] - The target component instance to run the function on.
* @returns {void}
*
* @example
* tryOnBeforeMount(() => console.log('Before mount'));
*
* @example
* tryOnBeforeMount(() => console.log('Before mount async'), { sync: false });
*
* @since 0.0.1
*/
export function tryOnBeforeMount(fn: VoidFunction, options: TryOnBeforeMountOptions = {}) {
const {
sync = true,
target,
} = options;
const instance = getLifeCycleTarger(target);
if (instance)
onBeforeMount(fn, instance);
else if (sync)
fn();
else
nextTick(fn);
}

View File

@@ -0,0 +1,57 @@
import { describe, it, vi, expect } from 'vitest';
import { defineComponent, nextTick, type PropType } from 'vue';
import { tryOnMounted } from '.';
import { mount } from '@vue/test-utils';
import type { VoidFunction } from '@robonen/stdlib';
const ComponentStub = defineComponent({
props: {
callback: {
type: Function as PropType<VoidFunction>,
},
},
setup(props) {
props.callback && tryOnMounted(props.callback);
},
template: `<div></div>`,
});
describe('tryOnMounted', () => {
it('run the callback when mounted', () => {
const callback = vi.fn();
mount(ComponentStub, {
props: { callback },
});
expect(callback).toHaveBeenCalled();
});
it('run the callback outside of a component lifecycle', () => {
const callback = vi.fn();
tryOnMounted(callback);
expect(callback).toHaveBeenCalled();
});
it('run the callback asynchronously', async () => {
const callback = vi.fn();
tryOnMounted(callback, { sync: false });
expect(callback).not.toHaveBeenCalled();
await nextTick();
expect(callback).toHaveBeenCalled();
});
it.skip('run the callback with a specific target', () => {
const callback = vi.fn();
const component = mount(ComponentStub);
tryOnMounted(callback, { target: component.vm.$ });
expect(callback).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,45 @@
import { onMounted, nextTick, type ComponentInternalInstance } from 'vue';
import { getLifeCycleTarger } from '../..';
import type { VoidFunction } from '@robonen/stdlib';
// TODO: tests
export interface TryOnMountedOptions {
sync?: boolean;
target?: ComponentInternalInstance;
}
/**
* @name tryOnMounted
* @category Components
* @description Call onMounted if it's inside a component lifecycle hook, otherwise just calls it
*
* @param {VoidFunction} fn The function to call
* @param {TryOnMountedOptions} options The options to use
* @param {boolean} [options.sync=true] If the function should be called synchronously
* @param {ComponentInternalInstance} [options.target] The target instance to use
* @returns {void}
*
* @example
* tryOnMounted(() => console.log('Mounted!'));
*
* @example
* tryOnMounted(() => console.log('Mounted!'), { sync: false });
*
* @since 0.0.1
*/
export function tryOnMounted(fn: VoidFunction, options: TryOnMountedOptions = {}) {
const {
sync = true,
target,
} = options;
const instance = getLifeCycleTarger(target);
if (instance)
onMounted(fn, instance);
else if (sync)
fn();
else
nextTick(fn);
}

View File

@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from 'vitest';
import { defineComponent, effectScope, type PropType } from 'vue';
import { tryOnScopeDispose } from '.';
import { mount } from '@vue/test-utils';
import type { VoidFunction } from '@robonen/stdlib';
const ComponentStub = defineComponent({
props: {
callback: {
type: Function as PropType<VoidFunction>,
required: true
}
},
setup(props) {
tryOnScopeDispose(props.callback);
},
template: '<div></div>',
});
describe('tryOnScopeDispose', () => {
it('returns false when the scope is not active', () => {
const callback = vi.fn();
const detectedScope = tryOnScopeDispose(callback);
expect(detectedScope).toBe(false);
expect(callback).not.toHaveBeenCalled();
});
it('run the callback when the scope is disposed', () => {
const callback = vi.fn();
const scope = effectScope();
let detectedScope: boolean | undefined;
scope.run(() => {
detectedScope = tryOnScopeDispose(callback);
});
expect(detectedScope).toBe(true);
expect(callback).not.toHaveBeenCalled();
scope.stop();
expect(callback).toHaveBeenCalled();
});
it('run callback when the component is unmounted', () => {
const callback = vi.fn();
const component = mount(ComponentStub, {
props: { callback },
});
expect(callback).not.toHaveBeenCalled();
component.unmount();
expect(callback).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,24 @@
import type { VoidFunction } from '@robonen/stdlib';
import { getCurrentScope, onScopeDispose } from 'vue';
/**
* @name tryOnScopeDispose
* @category Components
* @description A composable that will run a callback when the scope is disposed or do nothing if the scope isn't available.
*
* @param {VoidFunction} callback - The callback to run when the scope is disposed.
* @returns {boolean} - Returns true if the callback was run, otherwise false.
*
* @example
* tryOnScopeDispose(() => console.log('Scope disposed'));
*
* @since 0.0.1
*/
export function tryOnScopeDispose(callback: VoidFunction) {
if (getCurrentScope()) {
onScopeDispose(callback);
return true;
}
return false;
}

View File

@@ -0,0 +1,40 @@
import { describe, it, vi, expect } from 'vitest';
import { ref, reactive } from 'vue';
import { useAppSharedState } from '.';
describe('useAppSharedState', () => {
it('initialize state only once', () => {
const stateFactory = (initValue?: number) => {
const count = ref(initValue ?? 0);
return { count };
};
const useSharedState = useAppSharedState(stateFactory);
const state1 = useSharedState(1);
const state2 = useSharedState(2);
expect(state1.count.value).toBe(1);
expect(state2.count.value).toBe(1);
expect(state1).toBe(state2);
});
it('return the same state object across different calls', () => {
const stateFactory = () => {
const state = reactive({ count: 0 });
const increment = () => state.count++;
return { state, increment };
};
const useSharedState = useAppSharedState(stateFactory);
const sharedState1 = useSharedState();
const sharedState2 = useSharedState();
expect(sharedState1.state.count).toBe(0);
sharedState1.increment();
expect(sharedState1.state.count).toBe(1);
expect(sharedState2.state.count).toBe(1);
expect(sharedState1).toBe(sharedState2);
});
});

View File

@@ -0,0 +1,42 @@
import type { AnyFunction } from '@robonen/stdlib';
import { effectScope } from 'vue';
// TODO: maybe we should control subscriptions and dispose them when the child scope is disposed
/**
* @name useAppSharedState
* @category State
* @description Provides a shared state object for use across Vue instances
*
* @param {Function} stateFactory A factory function that returns the shared state object
* @returns {Function} A function that returns the shared state object
*
* @example
* const useSharedState = useAppSharedState((initValue?: number) => {
* const count = ref(initValue ?? 0);
* return { count };
* });
*
* @example
* const useSharedState = useAppSharedState(() => {
* const state = reactive({ count: 0 });
* const increment = () => state.count++;
* return { state, increment };
* });
*
* @since 0.0.1
*/
export function useAppSharedState<Fn extends AnyFunction>(stateFactory: Fn) {
let initialized = false;
let state: ReturnType<Fn>;
const scope = effectScope(true);
return ((...args: Parameters<Fn>) => {
if (!initialized) {
state = scope.run(() => stateFactory(...args));
initialized = true;
}
return state;
});
}

View File

@@ -0,0 +1,59 @@
import { ref, shallowRef } from 'vue';
import { isFunction } from '@robonen/stdlib';
export enum AsyncStateStatus {
PENDING,
FULFILLED,
REJECTED,
}
export interface UseAsyncStateOptions<Shallow extends boolean, Data = any> {
shallow?: Shallow;
immediate?: boolean;
resetOnExecute?: boolean;
throwError?: boolean;
onError?: (error: unknown) => void;
onSuccess?: (data: Data) => void;
}
/**
* @name useAsyncState
* @category State
* @description A composable that provides a state for async operations without setup blocking
*/
export function useAsyncState<Data, Params extends any[] = [], Shallow extends boolean = true>(
maybePromise: Promise<Data> | ((...args: Params) => Promise<Data>),
initialState: Data,
options?: UseAsyncStateOptions<Shallow, Data>,
) {
const state = options?.shallow ? shallowRef(initialState) : ref(initialState);
const status = ref<AsyncStateStatus | null>(null);
const execute = async (...params: any[]) => {
if (options?.resetOnExecute)
state.value = initialState;
status.value = AsyncStateStatus.PENDING;
const promise = isFunction(maybePromise) ? maybePromise(...params as Params) : maybePromise;
try {
const data = await promise;
state.value = data;
status.value = AsyncStateStatus.FULFILLED;
options?.onSuccess?.(data);
}
catch (error) {
status.value = AsyncStateStatus.REJECTED;
options?.onError?.(error);
if (options?.throwError)
throw error;
}
return state.value as Data;
};
if (options?.immediate)
execute();
}

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { ref, nextTick, reactive } from 'vue';
import { useCached } from '.';
const arrayEquals = (a: number[], b: number[]) => a.length === b.length && a.every((v, i) => v === b[i]);
describe('useCached', () => {
it('default comparator', async () => {
const externalValue = ref(0);
const cachedValue = useCached(externalValue);
expect(cachedValue.value).toBe(0);
externalValue.value = 1;
await nextTick();
expect(cachedValue.value).toBe(1);
});
it('custom array comparator', async () => {
const externalValue = ref([1]);
const initialValue = externalValue.value;
const cachedValue = useCached(externalValue, arrayEquals);
expect(cachedValue.value).toEqual(initialValue);
externalValue.value = initialValue;
await nextTick();
expect(cachedValue.value).toEqual(initialValue);
externalValue.value = [1];
await nextTick();
expect(cachedValue.value).toEqual(initialValue);
externalValue.value = [2];
await nextTick();
expect(cachedValue.value).not.toEqual(initialValue);
expect(cachedValue.value).toEqual([2]);
});
it('getter source', async () => {
const externalValue = reactive({ value: 0 });
const cachedValue = useCached(() => externalValue.value);
expect(cachedValue.value).toBe(0);
externalValue.value = 1;
await nextTick();
expect(cachedValue.value).toBe(1);
});
});

View File

@@ -0,0 +1,38 @@
import { ref, watch, toValue, type MaybeRefOrGetter, type Ref, type WatchOptions } from 'vue';
export type Comparator<Value> = (a: Value, b: Value) => boolean;
/**
* @name useCached
* @category Reactivity
* @description Caches the value of an external ref and updates it only when the value changes
*
* @param {Ref<T>} externalValue Ref to cache
* @param {Comparator<T>} comparator Comparator function to compare the values
* @param {WatchOptions} watchOptions Watch options
* @returns {Ref<T>} Cached ref
*
* @example
* const externalValue = ref(0);
* const cachedValue = useCached(externalValue);
*
* @example
* const externalValue = ref(0);
* const cachedValue = useCached(externalValue, (a, b) => a === b, { immediate: true });
*
* @since 0.0.1
*/
export function useCached<Value = unknown>(
externalValue: MaybeRefOrGetter<Value>,
comparator: Comparator<Value> = (a, b) => a === b,
watchOptions?: WatchOptions,
): Ref<Value> {
const cached = ref(toValue(externalValue)) as Ref<Value>;
watch(() => toValue(externalValue), (value) => {
if (!comparator(value, cached.value))
cached.value = value;
}, watchOptions);
return cached;
}

View File

@@ -0,0 +1,60 @@
import { ref, readonly, computed } from 'vue';
import { describe, it, expect } from 'vitest';
import { useClamp } from '.';
describe('useClamp', () => {
it('non-reactive values should be clamped', () => {
const clampedValue = useClamp(10, 0, 5);
expect(clampedValue.value).toBe(5);
});
it('clamp the value within the given range', () => {
const value = ref(10);
const clampedValue = useClamp(value, 0, 5);
expect(clampedValue.value).toBe(5);
});
it('clamp the value within the given range using functions', () => {
const value = ref(10);
const clampedValue = useClamp(value, () => 0, () => 5);
expect(clampedValue.value).toBe(5);
});
it('clamp readonly values', () => {
const computedValue = computed(() => 10);
const readonlyValue = readonly(ref(10));
const clampedValue1 = useClamp(computedValue, 0, 5);
const clampedValue2 = useClamp(readonlyValue, 0, 5);
expect(clampedValue1.value).toBe(5);
expect(clampedValue2.value).toBe(5);
});
it('update the clamped value when the original value changes', () => {
const value = ref(10);
const clampedValue = useClamp(value, 0, 5);
value.value = 3;
expect(clampedValue.value).toBe(3);
});
it('update the clamped value when the min or max changes', () => {
const value = ref(10);
const min = ref(0);
const max = ref(5);
const clampedValue = useClamp(value, min, max);
expect(clampedValue.value).toBe(5);
max.value = 15;
expect(clampedValue.value).toBe(10);
min.value = 11;
expect(clampedValue.value).toBe(11);
});
});

View File

@@ -0,0 +1,39 @@
import { clamp, isFunction } from '@robonen/stdlib';
import { computed, isReadonly, ref, toValue, type ComputedRef, type MaybeRef, type MaybeRefOrGetter, type WritableComputedRef } from 'vue';
/**
* @name useClamp
* @category Math
* @description Clamps a value between a minimum and maximum value
*
* @param {MaybeRefOrGetter<number>} value The value to clamp
* @param {MaybeRefOrGetter<number>} min The minimum value
* @param {MaybeRefOrGetter<number>} max The maximum value
* @returns {ComputedRef<number>} The clamped value
*
* @example
* const value = ref(10);
* const clampedValue = useClamp(value, 0, 5);
*
* @example
* const value = ref(10);
* const clampedValue = useClamp(value, () => 0, () => 5);
*
* @since 0.0.1
*/
export function useClamp(value: MaybeRef<number>, min: MaybeRefOrGetter<number>, max: MaybeRefOrGetter<number>): WritableComputedRef<number>;
export function useClamp(value: MaybeRefOrGetter<number>, min: MaybeRefOrGetter<number>, max: MaybeRefOrGetter<number>): ComputedRef<number> {
if (isFunction(value) || isReadonly(value))
return computed(() => clamp(toValue(value), toValue(min), toValue(max)));
const _value = ref(value);
return computed<number>({
get() {
return clamp(_value.value, toValue(min), toValue(max));
},
set(newValue) {
_value.value = clamp(newValue, toValue(min), toValue(max));
},
});
}

View File

@@ -0,0 +1,81 @@
import { describe, it, expect } from 'vitest';
import { defineComponent } from 'vue';
import { useContextFactory } from '.';
import { mount } from '@vue/test-utils';
import { VueToolsError } from '../../utils';
function testFactory<Data>(
data: Data,
context: ReturnType<typeof useContextFactory<Data>>,
fallback?: Data,
) {
const { inject, provide } = context;
const Child = defineComponent({
setup() {
const value = inject(fallback);
return { value };
},
template: `{{ value }}`,
});
const Parent = defineComponent({
components: { Child },
setup() {
provide(data);
},
template: `<Child />`,
});
return {
Parent,
Child,
};
}
// TODO: maybe replace template with passing mock functions to setup
describe('useContextFactory', () => {
it('provide and inject context correctly', () => {
const { Parent } = testFactory('test', useContextFactory('TestContext'));
const component = mount(Parent);
expect(component.text()).toBe('test');
});
it('throw an error when context is not provided', () => {
const { Child } = testFactory('test', useContextFactory('TestContext'));
expect(() => mount(Child)).toThrow(VueToolsError);
});
it('inject a fallback value when context is not provided', () => {
const { Child } = testFactory('test', useContextFactory('TestContext'), 'fallback');
const component = mount(Child);
expect(component.text()).toBe('fallback');
});
it('correctly handle null values', () => {
const { Parent } = testFactory(null, useContextFactory('TestContext'));
const component = mount(Parent);
expect(component.text()).toBe('');
});
it('provide context globally with app', () => {
const context = useContextFactory('TestContext');
const { Child } = testFactory(null, context);
const childComponent = mount(Child, {
global: {
plugins: [app => context.appProvide(app)('test')],
},
});
expect(childComponent.text()).toBe('test');
});
});

View File

@@ -0,0 +1,62 @@
import { inject, provide, type InjectionKey, type App } from 'vue';
import { VueToolsError } from '../..';
/**
* @name useContextFactory
* @category State
* @description A composable that provides a factory for creating context with unique key
*
* @param {string} name The name of the context
* @returns {Object} An object with `inject`, `provide`, `appProvide` and `key` properties
* @throws {VueToolsError} when the context is not provided
*
* @example
* const { inject, provide } = useContextFactory('MyContext');
*
* provide('Hello World');
* const value = inject();
*
* @example
* const { inject: injectContext, appProvide } = useContextFactory('MyContext');
*
* // In a plugin
* {
* install(app) {
* appProvide(app)('Hello World');
* }
* }
*
* // In a component
* const value = injectContext();
*
* @since 0.0.1
*/
export function useContextFactory<ContextValue>(name: string) {
const injectionKey: InjectionKey<ContextValue> = Symbol(name);
const injectContext = <Fallback extends ContextValue = ContextValue>(fallback?: Fallback) => {
const context = inject(injectionKey, fallback);
if (context !== undefined)
return context;
throw new VueToolsError(`useContextFactory: '${name}' context is not provided`);
};
const provideContext = (context: ContextValue) => {
provide(injectionKey, context);
return context;
};
const appProvide = (app: App) => (context: ContextValue) => {
app.provide(injectionKey, context);
return context;
};
return {
inject: injectContext,
provide: provideContext,
appProvide,
key: injectionKey,
}
}

View File

@@ -0,0 +1,8 @@
<script setup lang="ts">
</script>
<template>
<div>
</div>
</template>

View File

@@ -0,0 +1,81 @@
import { it, expect, describe } from 'vitest';
import { ref } from 'vue';
import { useCounter } from '.';
describe('useCounter', () => {
it('initialize count with the provided initial value', () => {
const { count } = useCounter(5);
expect(count.value).toBe(5);
});
it('initialize count with the provided initial value from a ref', () => {
const { count } = useCounter(ref(5));
expect(count.value).toBe(5);
});
it('initialize count with the provided initial value from a getter', () => {
const { count } = useCounter(() => 5);
expect(count.value).toBe(5);
});
it('increment count by 1 by default', () => {
const { count, increment } = useCounter(0);
increment();
expect(count.value).toBe(1);
});
it('increment count by the specified delta', () => {
const { count, increment } = useCounter(0);
increment(5);
expect(count.value).toBe(5);
});
it('decrement count by 1 by default', () => {
const { count, decrement } = useCounter(5);
decrement();
expect(count.value).toBe(4);
});
it('decrement count by the specified delta', () => {
const { count, decrement } = useCounter(10);
decrement(5);
expect(count.value).toBe(5);
});
it('set count to the specified value', () => {
const { count, set } = useCounter(0);
set(10);
expect(count.value).toBe(10);
});
it('get the current count value', () => {
const { get } = useCounter(5);
expect(get()).toBe(5);
});
it('reset count to the initial value', () => {
const { count, reset } = useCounter(10);
count.value = 5;
reset();
expect(count.value).toBe(10);
});
it('reset count to the specified value', () => {
const { count, reset } = useCounter(10);
count.value = 5;
reset(20);
expect(count.value).toBe(20);
});
it('clamp count to the minimum value', () => {
const { count, decrement } = useCounter(Number.MIN_SAFE_INTEGER);
decrement();
expect(count.value).toBe(Number.MIN_SAFE_INTEGER);
});
it('clamp count to the maximum value', () => {
const { count, increment } = useCounter(Number.MAX_SAFE_INTEGER);
increment();
expect(count.value).toBe(Number.MAX_SAFE_INTEGER);
});
});

View File

@@ -0,0 +1,73 @@
import { ref, toValue, type MaybeRefOrGetter, type Ref } from 'vue';
import { clamp } from '@robonen/stdlib';
export interface UseCounterOptions {
min?: number;
max?: number;
}
export interface UseConterReturn {
count: Ref<number>;
increment: (delta?: number) => void;
decrement: (delta?: number) => void;
set: (value: number) => void;
get: () => number;
reset: (value?: number) => void;
}
/**
* @name useCounter
* @category Utilities
* @description A composable that provides a counter with increment, decrement, set, get, and reset functions
*
* @param {MaybeRef<number>} [initialValue=0] The initial value of the counter
* @param {UseCounterOptions} [options={}] The options for the counter
* @param {number} [options.min=Number.MIN_SAFE_INTEGER] The minimum value of the counter
* @param {number} [options.max=Number.MAX_SAFE_INTEGER] The maximum value of the counter
* @returns {UseConterReturn} The counter object
*
* @example
* const { count, increment } = useCounter(0);
*
* @example
* const { count, increment, decrement, set, get, reset } = useCounter(0, { min: 0, max: 10 });
*
* @since 0.0.1
*/
export function useCounter(
initialValue: MaybeRefOrGetter<number> = 0,
options: UseCounterOptions = {},
): UseConterReturn {
let _initialValue = toValue(initialValue);
const count = ref(_initialValue);
const {
min = Number.MIN_SAFE_INTEGER,
max = Number.MAX_SAFE_INTEGER,
} = options;
const increment = (delta = 1) =>
count.value = clamp(count.value + delta, min, max);
const decrement = (delta = 1) =>
count.value = clamp(count.value - delta, min, max);
const set = (value: number) =>
count.value = clamp(value, min, max);
const get = () => count.value;
const reset = (value = _initialValue) => {
_initialValue = value;
return set(value);
};
return {
count,
increment,
decrement,
set,
get,
reset,
};
};

View File

@@ -0,0 +1,136 @@
import { isArray, isString, noop, type Arrayable, type VoidFunction } from '@robonen/stdlib';
import type { MaybeRefOrGetter } from 'vue';
import { defaultWindow } from '../..';
// TODO: wip
interface InferEventTarget<Events> {
addEventListener: (event: Events, listener?: any, options?: any) => any;
removeEventListener: (event: Events, listener?: any, options?: any) => any;
}
export interface GeneralEventListener<E = Event> {
(evt: E): void;
}
export type WindowEventName = keyof WindowEventMap;
export type DocumentEventName = keyof DocumentEventMap;
export type ElementEventName = keyof HTMLElementEventMap;
/**
* @name useEventListener
* @category Elements
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
*
* Overload 1: Omitted window target
*/
export function useEventListener<E extends WindowEventName>(
event: Arrayable<E>,
listener: Arrayable<(this: Window, ev: WindowEventMap[E]) => any>,
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>
): VoidFunction;
/**
* @name useEventListener
* @category Elements
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
*
* Overload 2: Explicit window target
*/
export function useEventListener<E extends WindowEventName>(
target: Window,
event: Arrayable<E>,
listener: Arrayable<(this: Window, ev: WindowEventMap[E]) => any>,
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>
): VoidFunction;
/**
* @name useEventListener
* @category Elements
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
*
* Overload 3: Explicit document target
*/
export function useEventListener<E extends DocumentEventName>(
target: Document,
event: Arrayable<E>,
listener: Arrayable<(this: Document, ev: DocumentEventMap[E]) => any>,
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>
): VoidFunction;
/**
* @name useEventListener
* @category Elements
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
*
* Overload 4: Explicit HTMLElement target
*/
export function useEventListener<E extends ElementEventName>(
target: MaybeRefOrGetter<HTMLElement | null | undefined>,
event: Arrayable<E>,
listener: Arrayable<(this: HTMLElement, ev: HTMLElementEventMap[E]) => any>,
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>
): VoidFunction;
/**
* @name useEventListener
* @category Elements
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
*
* Overload 5: Custom target with inferred event type
*/
export function useEventListener<Names extends string, EventType = Event>(
target: MaybeRefOrGetter<InferEventTarget<Names> | null | undefined>,
event: Arrayable<Names>,
listener: Arrayable<GeneralEventListener<EventType>>,
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>
)
/**
* @name useEventListener
* @category Elements
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
*
* Overload 6: Custom event target fallback
*/
export function useEventListener<EventType = Event>(
target: MaybeRefOrGetter<EventTarget | null | undefined>,
event: Arrayable<string>,
listener: Arrayable<GeneralEventListener<EventType>>,
options?: MaybeRefOrGetter<boolean | AddEventListenerOptions>
): VoidFunction;
export function useEventListener(...args: any[]) {
let target: MaybeRefOrGetter<EventTarget> | undefined;
let events: Arrayable<string>;
let listeners: Arrayable<Function>;
let options: MaybeRefOrGetter<boolean | AddEventListenerOptions> | undefined;
if (isString(args[0]) || isArray(args[0])) {
[events, listeners, options] = args;
target = defaultWindow;
} else {
[target, events, listeners, options] = args;
}
if (!target)
return noop;
if (!isArray(events))
events = [events];
if (!isArray(listeners))
listeners = [listeners];
const cleanups: Function[] = [];
const cleanup = () => {
cleanups.forEach(fn => fn());
cleanups.length = 0;
}
const register = (el: any, event: string, listener: any, options: any) => {
el.addEventListener(event, listener, options);
return () => el.removeEventListener(event, listener, options);
}
}

View File

@@ -0,0 +1,69 @@
import { describe, it, beforeEach, afterEach, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { defineComponent, nextTick } from 'vue';
import { useFocusGuard } from '.';
const setupFocusGuard = (namespace?: string) => {
return mount(
defineComponent({
setup() {
useFocusGuard(namespace);
},
template: '<div></div>',
})
);
};
const getFocusGuards = (namespace: string) =>
document.querySelectorAll(`[data-${namespace}]`);
describe('useFocusGuard', () => {
let component: ReturnType<typeof setupFocusGuard>;
const namespace = 'test-guard';
beforeEach(() => {
document.body.innerHTML = '';
});
afterEach(() => {
component.unmount();
});
it('create focus guards when mounted', async () => {
component = setupFocusGuard(namespace);
const guards = getFocusGuards(namespace);
expect(guards.length).toBe(2);
guards.forEach((guard) => {
expect(guard.getAttribute('tabindex')).toBe('0');
expect(guard.getAttribute('style')).toContain('opacity: 0');
});
});
it('remove focus guards when unmounted', () => {
component = setupFocusGuard(namespace);
component.unmount();
expect(getFocusGuards(namespace).length).toBe(0);
});
it('correctly manage multiple instances with the same namespace', () => {
const wrapper1 = setupFocusGuard(namespace);
const wrapper2 = setupFocusGuard(namespace);
// Guards should not be duplicated
expect(getFocusGuards(namespace).length).toBe(2);
wrapper1.unmount();
// Second instance still keeps the guards
expect(getFocusGuards(namespace).length).toBe(2);
wrapper2.unmount();
// No guards left after all instances are unmounted
expect(getFocusGuards(namespace).length).toBe(0);
});
});

View File

@@ -0,0 +1,40 @@
import { focusGuard } from '@robonen/platform/browsers';
import { onMounted, onUnmounted } from 'vue';
// Global counter to drop the focus guards when the last instance is unmounted
let counter = 0;
/**
* @name useFocusGuard
* @category Utilities
* @description Adds a pair of focus guards at the boundaries of the DOM tree to ensure consistent focus behavior
*
* @param {string} [namespace] - A namespace to group the focus guards
* @returns {void}
*
* @example
* useFocusGuard();
*
* @example
* useFocusGuard('my-namespace');
*
* @since 0.0.2
*/
export function useFocusGuard(namespace?: string) {
const manager = focusGuard(namespace);
const createGuard = () => {
manager.createGuard();
counter++;
};
const removeGuard = () => {
if (counter <= 1)
manager.removeGuard();
counter = Math.max(0, counter - 1);
};
onMounted(createGuard);
onUnmounted(removeGuard);
}

View File

@@ -0,0 +1,99 @@
import { describe, it, expect } from 'vitest';
import { defineComponent, ref } from 'vue';
import { useInjectionStore } from '.';
import { mount } from '@vue/test-utils';
function testFactory<Args, Return>(
store: ReturnType<typeof useInjectionStore<Args[], Return>>,
) {
const { useProvidingState, useInjectedState } = store;
const Child = defineComponent({
setup() {
const state = useInjectedState();
return { state };
},
template: `{{ state }}`,
});
const Parent = defineComponent({
components: { Child },
setup() {
const state = useProvidingState();
return { state };
},
template: `<Child />`,
});
return {
Parent,
Child,
};
}
describe('useInjectionState', () => {
it('provides and injects state correctly', () => {
const { Parent } = testFactory(
useInjectionStore(() => ref('base'))
);
const wrapper = mount(Parent);
expect(wrapper.text()).toBe('base');
});
it('injects default value when state is not provided', () => {
const { Child } = testFactory(
useInjectionStore(() => ref('without provider'), {
defaultValue: ref('default'),
injectionKey: 'testKey',
})
);
const wrapper = mount(Child);
expect(wrapper.text()).toBe('default');
});
it('provides state at app level', () => {
const injectionStore = useInjectionStore(() => ref('app level'));
const { Child } = testFactory(injectionStore);
const wrapper = mount(Child, {
global: {
plugins: [
app => {
const state = injectionStore.useAppProvidingState(app)();
expect(state.value).toBe('app level');
},
],
},
});
expect(wrapper.text()).toBe('app level');
});
it('works with custom injection key', () => {
const { Parent } = testFactory(
useInjectionStore(() => ref('custom key'), {
injectionKey: Symbol('customKey'),
}),
);
const wrapper = mount(Parent);
expect(wrapper.text()).toBe('custom key');
});
it('handles state factory with arguments', () => {
const injectionStore = useInjectionStore((arg: string) => arg);
const { Child } = testFactory(injectionStore);
const wrapper = mount(Child, {
global: {
plugins: [
app => injectionStore.useAppProvidingState(app)('with args'),
],
},
});
expect(wrapper.text()).toBe('with args');
});
});

View File

@@ -0,0 +1,72 @@
import { inject, provide, type App, type InjectionKey } from 'vue';
export interface useInjectionStoreOptions<Return> {
injectionKey: string | InjectionKey<Return>;
defaultValue?: Return;
}
/**
* @name useInjectionStore
* @category State
* @description Create a global state that can be injected into components
*
* @param {Function} stateFactory A factory function that creates the state
* @param {useInjectionStoreOptions} options An object with the following properties
* @param {string | InjectionKey} options.injectionKey The key to use for the injection
* @param {any} options.defaultValue The default value to use when the state is not provided
* @returns {Object} An object with `useProvidingState`, `useAppProvidingState`, and `useInjectedState` functions
*
* @example
* const { useProvidingState, useInjectedState } = useInjectionStore(() => ref('Hello World'));
*
* // In a parent component
* const state = useProvidingState();
*
* // In a child component
* const state = useInjectedState();
*
* @example
* const { useProvidingState, useInjectedState } = useInjectionStore(() => ref('Hello World'), {
* injectionKey: 'MyState',
* defaultValue: 'Default Value'
* });
*
* // In a plugin
* {
* install(app) {
* const state = useAppProvidingState(app)();
* state.value = 'Hello World';
* }
* }
*
* // In a component
* const state = useInjectedState();
*
* @since 0.0.5
*/
export function useInjectionStore<Args extends any[], Return>(
stateFactory: (...args: Args) => Return,
options?: useInjectionStoreOptions<Return>,
) {
const key = options?.injectionKey ?? Symbol(stateFactory.name ?? 'InjectionStore');
const useProvidingState = (...args: Args) => {
const state = stateFactory(...args);
provide(key, state);
return state;
};
const useAppProvidingState = (app: App) => (...args: Args) => {
const state = stateFactory(...args);
app.provide(key, state);
return state;
};
const useInjectedState = () => inject(key, options?.defaultValue);
return {
useProvidingState,
useAppProvidingState,
useInjectedState
};
}

View File

@@ -0,0 +1,50 @@
import { ref, nextTick } from 'vue';
import { describe, it, expect } from 'vitest';
import { useLastChanged } from '.';
import { timestamp } from '@robonen/stdlib';
describe('useLastChanged', () => {
it('initialize with null if no initialValue is provided', () => {
const source = ref(0);
const lastChanged = useLastChanged(source);
expect(lastChanged.value).toBeNull();
});
it('initialize with the provided initialValue', () => {
const source = ref(0);
const initialValue = 123456789;
const lastChanged = useLastChanged(source, { initialValue });
expect(lastChanged.value).toBe(initialValue);
});
it('update the timestamp when the source changes', async () => {
const source = ref(0);
const lastChanged = useLastChanged(source);
const initialTimestamp = lastChanged.value;
source.value = 1;
await nextTick();
expect(lastChanged.value).not.toBe(initialTimestamp);
expect(lastChanged.value).toBeLessThanOrEqual(timestamp());
});
it('update the timestamp immediately if immediate option is true', async () => {
const source = ref(0);
const lastChanged = useLastChanged(source, { immediate: true });
expect(lastChanged.value).toBeLessThanOrEqual(timestamp());
});
it('not update the timestamp if the source does not change', async () => {
const source = ref(0);
const lastChanged = useLastChanged(source);
const initialTimestamp = lastChanged.value;
await nextTick();
expect(lastChanged.value).toBe(initialTimestamp);
});
});

View File

@@ -0,0 +1,38 @@
import { timestamp } from '@robonen/stdlib';
import { ref, watch, type WatchSource, type WatchOptions, type Ref } from 'vue';
export interface UseLastChangedOptions<
Immediate extends boolean,
InitialValue extends number | null | undefined = undefined,
> extends WatchOptions<Immediate> {
initialValue?: InitialValue;
}
/**
* @name useLastChanged
* @category State
* @description Records the last time a value changed
*
* @param {WatchSource} source The value to track
* @param {UseLastChangedOptions} [options={}] The options for the last changed tracker
* @returns {Ref<number | null>} The timestamp of the last change
*
* @example
* const value = ref(0);
* const lastChanged = useLastChanged(value);
*
* @example
* const value = ref(0);
* const lastChanged = useLastChanged(value, { immediate: true });
*
* @since 0.0.1
*/
export function useLastChanged(source: WatchSource, options?: UseLastChangedOptions<false>): Ref<number | null>;
export function useLastChanged(source: WatchSource, options: UseLastChangedOptions<true> | UseLastChangedOptions<boolean, number>): Ref<number>
export function useLastChanged(source: WatchSource, options: UseLastChangedOptions<boolean, any> = {}): Ref<number | null> | Ref<number> {
const lastChanged = ref<number | null>(options.initialValue ?? null);
watch(source, () => lastChanged.value = timestamp(), options);
return lastChanged;
}

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { defineComponent, nextTick, ref } from 'vue';
import { mount } from '@vue/test-utils'
import { useMounted } from '.';
const ComponentStub = defineComponent({
setup() {
const isMounted = useMounted();
return { isMounted };
},
template: `<div>{{ isMounted }}</div>`,
});
describe('useMounted', () => {
it('return the mounted state of the component', async () => {
const component = mount(ComponentStub);
// Initial render
expect(component.text()).toBe('false');
await nextTick();
// Will trigger a render
expect(component.text()).toBe('true');
});
});

View File

@@ -0,0 +1,27 @@
import { onMounted, readonly, ref, type ComponentInternalInstance } from 'vue';
import { getLifeCycleTarger } from '../..';
/**
* @name useMounted
* @category Components
* @description Returns a ref that tracks the mounted state of the component (doesn't track the unmounted state)
*
* @param {ComponentInternalInstance} [instance] The component instance to track the mounted state for
* @returns {Readonly<Ref<boolean>>} The mounted state of the component
*
* @example
* const isMounted = useMounted();
*
* @example
* const isMounted = useMounted(getCurrentInstance());
*
* @since 0.0.1
*/
export function useMounted(instance?: ComponentInternalInstance) {
const isMounted = ref(false);
const targetInstance = getLifeCycleTarger(instance);
onMounted(() => isMounted.value = true, targetInstance);
return readonly(isMounted);
}

View File

@@ -0,0 +1,147 @@
import { describe, it, expect, vi } from 'vitest';
import { nextTick, ref } from 'vue';
import { useOffsetPagination } from '.';
describe('useOffsetPagination', () => {
it('initialize with default values without options', () => {
const { currentPage, currentPageSize, totalPages, isFirstPage } = useOffsetPagination({});
expect(currentPage.value).toBe(1);
expect(currentPageSize.value).toBe(10);
expect(totalPages.value).toBe(Infinity);
expect(isFirstPage.value).toBe(true);
});
it('calculate total pages correctly', () => {
const { totalPages } = useOffsetPagination({ total: 100, pageSize: 10 });
expect(totalPages.value).toBe(10);
});
it('update current page correctly', () => {
const { currentPage, next, previous, select } = useOffsetPagination({ total: 100, pageSize: 10 });
next();
expect(currentPage.value).toBe(2);
previous();
expect(currentPage.value).toBe(1);
select(5);
expect(currentPage.value).toBe(5);
});
it('handle out of bounds increments correctly', () => {
const { currentPage, next, previous } = useOffsetPagination({ total: 10, pageSize: 5 });
next();
next();
next();
expect(currentPage.value).toBe(2);
previous();
previous();
previous();
expect(currentPage.value).toBe(1);
});
it('handle page boundaries correctly', () => {
const { currentPage, isFirstPage, isLastPage } = useOffsetPagination({ total: 20, pageSize: 10 });
expect(currentPage.value).toBe(1);
expect(isFirstPage.value).toBe(true);
expect(isLastPage.value).toBe(false);
currentPage.value = 2;
expect(currentPage.value).toBe(2);
expect(isFirstPage.value).toBe(false);
expect(isLastPage.value).toBe(true);
});
it('call onPageChange callback', async () => {
const onPageChange = vi.fn();
const { currentPage, next } = useOffsetPagination({ total: 100, pageSize: 10, onPageChange });
next();
await nextTick();
expect(onPageChange).toHaveBeenCalledTimes(1);
expect(onPageChange).toHaveBeenCalledWith(expect.objectContaining({ currentPage: currentPage.value }));
});
it('call onPageSizeChange callback', async () => {
const onPageSizeChange = vi.fn();
const pageSize = ref(10);
const { currentPageSize } = useOffsetPagination({ total: 100, pageSize, onPageSizeChange });
pageSize.value = 20;
await nextTick();
expect(onPageSizeChange).toHaveBeenCalledTimes(1);
expect(onPageSizeChange).toHaveBeenCalledWith(expect.objectContaining({ currentPageSize: currentPageSize.value }));
});
it('call onPageCountChange callback', async () => {
const onTotalPagesChange = vi.fn();
const total = ref(100);
const { totalPages } = useOffsetPagination({ total, pageSize: 10, onTotalPagesChange });
total.value = 200;
await nextTick();
expect(onTotalPagesChange).toHaveBeenCalledTimes(1);
expect(onTotalPagesChange).toHaveBeenCalledWith(expect.objectContaining({ totalPages: totalPages.value }));
});
it('handle complex reactive options', async () => {
const total = ref(100);
const pageSize = ref(10);
const page = ref(1);
const onPageChange = vi.fn();
const onPageSizeChange = vi.fn();
const onTotalPagesChange = vi.fn();
const { currentPage, currentPageSize, totalPages } = useOffsetPagination({
total,
pageSize,
page,
onPageChange,
onPageSizeChange,
onTotalPagesChange,
});
// Initial values
expect(currentPage.value).toBe(1);
expect(currentPageSize.value).toBe(10);
expect(totalPages.value).toBe(10);
expect(onPageChange).toHaveBeenCalledTimes(0);
expect(onPageSizeChange).toHaveBeenCalledTimes(0);
expect(onTotalPagesChange).toHaveBeenCalledTimes(0);
total.value = 300;
pageSize.value = 15;
page.value = 2;
await nextTick();
// Valid values after changes
expect(currentPage.value).toBe(2);
expect(currentPageSize.value).toBe(15);
expect(totalPages.value).toBe(20);
expect(onPageChange).toHaveBeenCalledTimes(1);
expect(onPageSizeChange).toHaveBeenCalledTimes(1);
expect(onTotalPagesChange).toHaveBeenCalledTimes(1);
page.value = 21;
await nextTick();
// Invalid values after changes
expect(currentPage.value).toBe(20);
expect(onPageChange).toHaveBeenCalledTimes(2);
expect(onPageSizeChange).toHaveBeenCalledTimes(1);
expect(onTotalPagesChange).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,126 @@
import type { VoidFunction } from '@robonen/stdlib';
import { computed, reactive, toValue, watch, type ComputedRef, type MaybeRef, type MaybeRefOrGetter, type UnwrapNestedRefs, type WritableComputedRef } from 'vue';
import { useClamp } from '../useClamp';
// TODO: sync returned refs with passed refs
export interface UseOffsetPaginationOptions {
total?: MaybeRefOrGetter<number>;
pageSize?: MaybeRef<number>;
page?: MaybeRef<number>;
onPageChange?: (returnValue: UnwrapNestedRefs<UseOffsetPaginationReturn>) => unknown;
onPageSizeChange?: (returnValue: UnwrapNestedRefs<UseOffsetPaginationReturn>) => unknown;
onTotalPagesChange?: (returnValue: UnwrapNestedRefs<UseOffsetPaginationReturn>) => unknown;
}
export interface UseOffsetPaginationReturn {
currentPage: WritableComputedRef<number>;
currentPageSize: WritableComputedRef<number>;
totalPages: ComputedRef<number>;
isFirstPage: ComputedRef<boolean>;
isLastPage: ComputedRef<boolean>;
next: VoidFunction;
previous: VoidFunction;
select: (page: number) => void;
}
export type UseOffsetPaginationInfinityReturn = Omit<UseOffsetPaginationReturn, 'isLastPage'>;
/**
* @name useOffsetPagination
* @category Utilities
* @description A composable function that provides pagination functionality for offset based pagination
*
* @param {UseOffsetPaginationOptions} options The options for the pagination
* @param {MaybeRefOrGetter<number>} options.total The total number of items
* @param {MaybeRef<number>} options.pageSize The number of items per page
* @param {MaybeRef<number>} options.page The current page
* @param {(returnValue: UnwrapNestedRefs<UseOffsetPaginationReturn>) => unknown} options.onPageChange A callback that is called when the page changes
* @param {(returnValue: UnwrapNestedRefs<UseOffsetPaginationReturn>) => unknown} options.onPageSizeChange A callback that is called when the page size changes
* @param {(returnValue: UnwrapNestedRefs<UseOffsetPaginationReturn>) => unknown} options.onTotalPagesChange A callback that is called when the total number of pages changes
* @returns {UseOffsetPaginationReturn} The pagination object
*
* @example
* const {
* currentPage,
* currentPageSize,
* totalPages,
* isFirstPage,
* isLastPage,
* next,
* previous,
* select,
* } = useOffsetPagination({ total: 100, pageSize: 10, page: 1 });
*
* @example
* const {
* currentPage,
* } = useOffsetPagination({
* total: 100,
* pageSize: 10,
* page: 1,
* onPageChange: ({ currentPage }) => console.log(currentPage),
* onPageSizeChange: ({ currentPageSize }) => console.log(currentPageSize),
* onTotalPagesChange: ({ totalPages }) => console.log(totalPages),
* });
*
* @since 0.0.1
*/
export function useOffsetPagination(options: Omit<UseOffsetPaginationOptions, 'total'>): UseOffsetPaginationInfinityReturn;
export function useOffsetPagination(options: UseOffsetPaginationOptions): UseOffsetPaginationReturn;
export function useOffsetPagination(options: UseOffsetPaginationOptions): UseOffsetPaginationReturn {
const {
total = Number.POSITIVE_INFINITY,
pageSize = 10,
page = 1,
} = options;
const currentPageSize = useClamp(pageSize, 1, Number.POSITIVE_INFINITY);
const totalPages = computed(() => Math.max(
1,
Math.ceil(toValue(total) / toValue(currentPageSize))
));
const currentPage = useClamp(page, 1, totalPages);
const isFirstPage = computed(() => currentPage.value === 1);
const isLastPage = computed(() => currentPage.value === totalPages.value);
const next = () => currentPage.value++;
const previous = () => currentPage.value--;
const select = (page: number) => currentPage.value = page;
const returnValue = {
currentPage,
currentPageSize,
totalPages,
isFirstPage,
isLastPage,
next,
previous,
select,
};
// NOTE: Don't forget to await nextTick() after calling next() or previous() to ensure the callback is called
if (options.onPageChange) {
watch(currentPage, () => {
options.onPageChange!(reactive(returnValue));
});
}
if (options.onPageSizeChange) {
watch(currentPageSize, () => {
options.onPageSizeChange!(reactive(returnValue));
});
}
if (options.onTotalPagesChange) {
watch(totalPages, () => {
options.onTotalPagesChange!(reactive(returnValue));
});
}
return returnValue;
}

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import { defineComponent, nextTick, ref } from 'vue';
import { mount } from '@vue/test-utils'
import { useRenderCount } from '.';
const ComponentStub = defineComponent({
setup() {
const count = useRenderCount();
const visibleCount = ref(0);
const hiddenCount = ref(0);
return { count, visibleCount, hiddenCount };
},
template: `<div>{{ visibleCount }}</div>`,
});
describe('useRenderCount', () => {
it('return the number of times the component has been rendered', async () => {
const component = mount(ComponentStub);
// Initial render
expect(component.vm.count).toBe(1);
component.vm.hiddenCount = 1;
await nextTick();
// Will not trigger a render
expect(component.vm.count).toBe(1);
expect(component.text()).toBe('0');
component.vm.visibleCount++;
await nextTick();
// Will trigger a render
expect(component.vm.count).toBe(2);
expect(component.text()).toBe('1');
component.vm.visibleCount++;
component.vm.visibleCount++;
await nextTick();
// Will trigger a single render for both updates
expect(component.vm.count).toBe(3);
expect(component.text()).toBe('3');
});
it('can be used with a specific component instance', async () => {
const component = mount(ComponentStub);
const instance = component.vm.$;
const count = useRenderCount(instance);
// Initial render (should be zero because the component has already rendered on mount)
expect(count.value).toBe(0);
component.vm.hiddenCount = 1;
await nextTick();
// Will not trigger a render
expect(count.value).toBe(0);
component.vm.visibleCount++;
await nextTick();
// Will trigger a render
expect(count.value).toBe(1);
component.vm.visibleCount++;
component.vm.visibleCount++;
await nextTick();
// Will trigger a single render for both updates
expect(count.value).toBe(2);
});
});

View File

@@ -0,0 +1,29 @@
import { onMounted, onUpdated, readonly, type ComponentInternalInstance } from 'vue';
import { useCounter } from '../useCounter';
import { getLifeCycleTarger } from '../..';
/**
* @name useRenderCount
* @category Components
* @description Returns the number of times the component has been rendered into the DOM
*
* @param {ComponentInternalInstance} [instance] The component instance to track the render count for
* @returns {Readonly<Ref<number>>} The number of times the component has been rendered
*
* @example
* const count = useRenderCount();
*
* @example
* const count = useRenderCount(getCurrentInstance());
*
* @since 0.0.1
*/
export function useRenderCount(instance?: ComponentInternalInstance) {
const { count, increment } = useCounter(0);
const target = getLifeCycleTarger(instance);
onMounted(increment, target);
onUpdated(increment, target);
return readonly(count);
}

View File

@@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest';
import { useRenderInfo } from '.';
import { defineComponent, nextTick, ref } from 'vue';
import { mount } from '@vue/test-utils';
const NamedComponentStub = defineComponent({
name: 'ComponentStub',
setup() {
const info = useRenderInfo();
const visibleCount = ref(0);
const hiddenCount = ref(0);
return { info, visibleCount, hiddenCount };
},
template: `<div>{{ visibleCount }}</div>`,
});
const UnnamedComponentStub = defineComponent({
setup() {
const info = useRenderInfo();
const visibleCount = ref(0);
const hiddenCount = ref(0);
return { info, visibleCount, hiddenCount };
},
template: `<div>{{ visibleCount }}</div>`,
});
describe('useRenderInfo', () => {
it('return uid if component name is not available', async () => {
const wrapper = mount(UnnamedComponentStub);
expect(wrapper.vm.info.component).toBe(wrapper.vm.$.uid);
});
it('return render info for the given instance', async () => {
const wrapper = mount(NamedComponentStub);
// Initial render
expect(wrapper.vm.info.component).toBe('ComponentStub');
expect(wrapper.vm.info.count.value).toBe(1);
expect(wrapper.vm.info.duration.value).toBeGreaterThan(0);
expect(wrapper.vm.info.lastRendered).toBeGreaterThan(0);
let lastRendered = wrapper.vm.info.lastRendered;
let duration = wrapper.vm.info.duration.value;
// Will not trigger a render
wrapper.vm.hiddenCount++;
await nextTick();
expect(wrapper.vm.info.component).toBe('ComponentStub');
expect(wrapper.vm.info.count.value).toBe(1);
expect(wrapper.vm.info.duration.value).toBe(duration);
expect(wrapper.vm.info.lastRendered).toBe(lastRendered);
// Will trigger a render
wrapper.vm.visibleCount++;
await nextTick();
expect(wrapper.vm.info.component).toBe('ComponentStub');
expect(wrapper.vm.info.count.value).toBe(2);
expect(wrapper.vm.info.duration.value).not.toBe(duration);
expect(wrapper.vm.info.lastRendered).toBeGreaterThan(0);
});
it('can be used with a specific component instance', async () => {
const wrapper = mount(NamedComponentStub);
const instance = wrapper.vm.$;
const info = useRenderInfo(instance);
// Initial render (should be zero because the component has already rendered on mount)
expect(info.component).toBe('ComponentStub');
expect(info.count.value).toBe(0);
expect(info.duration.value).toBe(0);
expect(info.lastRendered).toBeGreaterThan(0);
let lastRendered = info.lastRendered;
let duration = info.duration.value;
// Will not trigger a render
wrapper.vm.hiddenCount++;
await nextTick();
expect(info.component).toBe('ComponentStub');
expect(info.count.value).toBe(0);
expect(info.duration.value).toBe(duration);
expect(info.lastRendered).toBe(lastRendered);
// Will trigger a render
wrapper.vm.visibleCount++;
await nextTick();
expect(info.component).toBe('ComponentStub');
expect(info.count.value).toBe(1);
expect(info.duration.value).not.toBe(duration);
expect(info.lastRendered).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,45 @@
import { timestamp } from '@robonen/stdlib';
import { onBeforeMount, onBeforeUpdate, onMounted, onUpdated, readonly, ref, type ComponentInternalInstance } from 'vue';
import { useRenderCount } from '../useRenderCount';
import { getLifeCycleTarger } from '../..';
/**
* @name useRenderInfo
* @category Components
* @description Returns information about the component's render count and the last time it was rendered
*
* @param {ComponentInternalInstance} [instance] The component instance to track the render count for
*
*
* @example
* const { component, count, duration, lastRendered } = useRenderInfo();
*
* @example
* const { component, count, duration, lastRendered } = useRenderInfo(getCurrentInstance());
*
* @since 0.0.1
*/
export function useRenderInfo(instance?: ComponentInternalInstance) {
const target = getLifeCycleTarger(instance);
const duration = ref(0);
let renderStartTime = 0;
const startMark = () => renderStartTime = performance.now();
const endMark = () => {
duration.value = Math.max(performance.now() - renderStartTime, 0);
renderStartTime = 0;
};
onBeforeMount(startMark, target);
onMounted(endMark, target);
onBeforeUpdate(startMark, target);
onUpdated(endMark, target);
return {
component: target?.type.name ?? target?.uid,
count: useRenderCount(instance),
duration: readonly(duration),
lastRendered: timestamp(),
};
}

View File

@@ -0,0 +1,37 @@
import { defineComponent } from 'vue';
import { describe, it, expect } from 'vitest';
import { useSupported } from '.';
import { mount } from '@vue/test-utils';
const ComponentStub = defineComponent({
props: {
location: {
type: String,
default: 'location',
},
},
setup(props) {
const isSupported = useSupported(() => props.location in window);
return { isSupported };
},
template: `<div>{{ isSupported }}</div>`,
});
describe('useSupported', () => {
it('return whether the feature is supported', async () => {
const component = mount(ComponentStub);
expect(component.text()).toBe('true');
});
it('return whether the feature is not supported', async () => {
const component = mount(ComponentStub, {
props: {
location: 'unsupported',
},
});
expect(component.text()).toBe('false');
});
});

View File

@@ -0,0 +1,29 @@
import { computed } from 'vue';
import { useMounted } from '../useMounted';
/**
* @name useSupported
* @category Utilities
* @description SSR-friendly way to check if a feature is supported
*
* @param {Function} feature The feature to check for support
* @returns {ComputedRef<boolean>} Whether the feature is supported
*
* @example
* const isSupported = useSupported(() => 'IntersectionObserver' in window);
*
* @example
* const isSupported = useSupported(() => 'ResizeObserver' in window);
*
* @since 0.0.1
*/
export function useSupported(feature: () => unknown) {
const isMounted = useMounted();
return computed(() => {
// add reactive dependency on isMounted
isMounted.value;
return Boolean(feature());
});
}

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { ref } from 'vue';
import { useSyncRefs } from '.';
describe('useSyncRefs', () => {
it('sync the value of a source ref with multiple target refs', () => {
const source = ref(0);
const target1 = ref(0);
const target2 = ref(0);
useSyncRefs(source, [target1, target2]);
source.value = 10;
expect(target1.value).toBe(10);
expect(target2.value).toBe(10);
});
it('sync the value of a source ref with a single target ref', () => {
const source = ref(0);
const target = ref(0);
useSyncRefs(source, target);
source.value = 20;
expect(target.value).toBe(20);
});
it('stop watching when the stop handle is called', () => {
const source = ref(0);
const target = ref(0);
const stop = useSyncRefs(source, target);
source.value = 30;
stop();
source.value = 40;
expect(target.value).toBe(30);
});
});

View File

@@ -0,0 +1,46 @@
import { watch, type Ref, type WatchOptions, type WatchSource } from 'vue';
import { isArray } from '@robonen/stdlib';
/**
* @name useSyncRefs
* @category Reactivity
* @description Syncs the value of a source ref with multiple target refs
*
* @param {WatchSource<T>} source Source ref to sync
* @param {Ref<T> | Ref<T>[]} targets Target refs to sync
* @param {WatchOptions} watchOptions Watch options
* @returns {WatchStopHandle} Watch stop handle
*
* @example
* const source = ref(0);
* const target1 = ref(0);
* const target2 = ref(0);
* useSyncRefs(source, [target1, target2]);
*
* @example
* const source = ref(0);
* const target1 = ref(0);
* useSyncRefs(source, target1, { immediate: true });
*
* @since 0.0.1
*/
export function useSyncRefs<T = unknown>(
source: WatchSource<T>,
targets: Ref<T> | Ref<T>[],
watchOptions: WatchOptions = {},
) {
const {
flush = 'sync',
deep = false,
immediate = true,
} = watchOptions;
if (!isArray(targets))
targets = [targets];
return watch(
source,
(value) => targets.forEach((target) => target.value = value),
{ flush, deep, immediate },
);
}

View File

@@ -0,0 +1,51 @@
import { isRef, ref, toValue, type MaybeRefOrGetter, type MaybeRef, type Ref } from 'vue';
// TODO: wip
export interface UseToggleOptions<Enabled, Disabled> {
enabledValue?: MaybeRefOrGetter<Enabled>,
disabledValue?: MaybeRefOrGetter<Disabled>,
}
// two overloads
// 1. const [state, toggle] = useToggle(nonRefValue, options)
// 2. const toggle = useToggle(refValue, options)
// 3. const [state, toggle] = useToggle() // true, false by default
export function useToggle<V extends Enabled | Disabled, Enabled = true, Disabled = false>(
initialValue: Ref<V>,
options?: UseToggleOptions<Enabled, Disabled>,
): (value?: V) => V;
export function useToggle<V extends Enabled | Disabled, Enabled = true, Disabled = false>(
initialValue?: V,
options?: UseToggleOptions<Enabled, Disabled>,
): [Ref<V>, (value?: V) => V];
export function useToggle<V extends Enabled | Disabled, Enabled = true, Disabled = false>(
initialValue: MaybeRef<V> = false,
options: UseToggleOptions<Enabled, Disabled> = {},
) {
const {
enabledValue = false,
disabledValue = true,
} = options;
const state = ref(initialValue) as Ref<V>;
const toggle = (value?: V) => {
if (arguments.length) {
state.value = value!;
return state.value;
}
const enabled = toValue(enabledValue);
const disabled = toValue(disabledValue);
state.value = state.value === enabled ? disabled : enabled;
return state.value;
};
return isRef(initialValue) ? toggle : [state, toggle];
}

3
web/vue/src/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './composables';
export * from './utils';
export * from './types';

View File

@@ -0,0 +1,2 @@
export * from './resumable';
export * from './window';

View File

@@ -0,0 +1,30 @@
/**
* Often times, we want to pause and resume a process. This is a common pattern in
* reactive programming. This interface defines the options and actions for a resumable
* process.
*/
/**
* The options for a resumable process.
*
* @typedef {Object} ResumableOptions
* @property {boolean} [immediate] Whether to immediately resume the process
*
*/
export interface ResumableOptions {
immediate?: boolean;
}
/**
* The actions for a resumable process.
*
* @typedef {Object} ResumableActions
* @property {Function} resume Resumes the process
* @property {Function} pause Pauses the process
* @property {Function} toggle Toggles the process
*/
export interface ResumableActions {
resume: () => void;
pause: () => void;
toggle: () => void;
}

View File

@@ -0,0 +1,3 @@
import { isClient } from '@robonen/platform/multi';
export const defaultWindow = /* #__PURE__ */ isClient ? window : undefined

View File

@@ -0,0 +1,21 @@
import { getCurrentInstance, type ComponentInternalInstance } from 'vue';
/**
* @name getLifeCycleTarger
* @category Utils
* @description Function to get the target instance of the lifecycle hook
*
* @param {ComponentInternalInstance} target The target instance of the lifecycle hook
* @returns {ComponentInternalInstance | null} Instance of the lifecycle hook or null
*
* @example
* const target = getLifeCycleTarger();
*
* @example
* const target = getLifeCycleTarger(instance);
*
* @since 0.0.1
*/
export function getLifeCycleTarger(target?: ComponentInternalInstance) {
return target || getCurrentInstance();
}

View File

@@ -0,0 +1,13 @@
/**
* @name VueToolsError
* @category Error
* @description VueToolsError is a custom error class that represents an error in Vue Tools
*
* @since 0.0.1
*/
export class VueToolsError extends Error {
constructor(message: string) {
super(message);
this.name = 'VueToolsError';
}
}

View File

@@ -0,0 +1,2 @@
export * from './components';
export * from './error';