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:
1
web/vue/README.md
Normal file
1
web/vue/README.md
Normal file
@@ -0,0 +1 @@
|
||||
# @robonen/vue
|
||||
11
web/vue/build.config.ts
Normal file
11
web/vue/build.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
externals: ['vue'],
|
||||
rollup: {
|
||||
inlineDependencies: true,
|
||||
esbuild: {
|
||||
// minify: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
7
web/vue/jsr.json
Normal file
7
web/vue/jsr.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://jsr.io/schema/config-file.v1.json",
|
||||
"name": "@robonen/vue",
|
||||
"license": "Apache-2.0",
|
||||
"version": "0.0.7",
|
||||
"exports": "./src/index.ts"
|
||||
}
|
||||
49
web/vue/package.json
Normal file
49
web/vue/package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@robonen/vue",
|
||||
"version": "0.0.7",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Collection of powerful tools for Vue",
|
||||
"keywords": [
|
||||
"vue",
|
||||
"tools",
|
||||
"ui",
|
||||
"utilities",
|
||||
"composables"
|
||||
],
|
||||
"author": "Robonen Andrew <robonenandrew@gmail.com>",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/robonen/tools.git",
|
||||
"directory": "./packages/vue"
|
||||
},
|
||||
"packageManager": "pnpm@10.11.0",
|
||||
"engines": {
|
||||
"node": ">=22.15.1"
|
||||
},
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"dev": "vitest dev",
|
||||
"build": "unbuild"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@robonen/platform": "workspace:*",
|
||||
"@robonen/stdlib": "workspace:*",
|
||||
"@robonen/tsconfig": "workspace:*",
|
||||
"@vue/test-utils": "catalog:",
|
||||
"unbuild": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "catalog:"
|
||||
}
|
||||
}
|
||||
17
web/vue/src/composables/index.ts
Normal file
17
web/vue/src/composables/index.ts
Normal 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';
|
||||
45
web/vue/src/composables/tryOnBeforeMount/index.ts
Normal file
45
web/vue/src/composables/tryOnBeforeMount/index.ts
Normal 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);
|
||||
}
|
||||
57
web/vue/src/composables/tryOnMounted/index.test.ts
Normal file
57
web/vue/src/composables/tryOnMounted/index.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
45
web/vue/src/composables/tryOnMounted/index.ts
Normal file
45
web/vue/src/composables/tryOnMounted/index.ts
Normal 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);
|
||||
}
|
||||
58
web/vue/src/composables/tryOnScopeDispose/index.test.ts
Normal file
58
web/vue/src/composables/tryOnScopeDispose/index.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
24
web/vue/src/composables/tryOnScopeDispose/index.ts
Normal file
24
web/vue/src/composables/tryOnScopeDispose/index.ts
Normal 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;
|
||||
}
|
||||
40
web/vue/src/composables/useAppSharedState/index.test.ts
Normal file
40
web/vue/src/composables/useAppSharedState/index.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
42
web/vue/src/composables/useAppSharedState/index.ts
Normal file
42
web/vue/src/composables/useAppSharedState/index.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
59
web/vue/src/composables/useAsyncState/index.ts
Normal file
59
web/vue/src/composables/useAsyncState/index.ts
Normal 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();
|
||||
}
|
||||
51
web/vue/src/composables/useCached/index.test.ts
Normal file
51
web/vue/src/composables/useCached/index.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
38
web/vue/src/composables/useCached/index.ts
Normal file
38
web/vue/src/composables/useCached/index.ts
Normal 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;
|
||||
}
|
||||
60
web/vue/src/composables/useClamp/index.test.ts
Normal file
60
web/vue/src/composables/useClamp/index.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
39
web/vue/src/composables/useClamp/index.ts
Normal file
39
web/vue/src/composables/useClamp/index.ts
Normal 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));
|
||||
},
|
||||
});
|
||||
}
|
||||
81
web/vue/src/composables/useContextFactory/index.test.ts
Normal file
81
web/vue/src/composables/useContextFactory/index.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
62
web/vue/src/composables/useContextFactory/index.ts
Normal file
62
web/vue/src/composables/useContextFactory/index.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
8
web/vue/src/composables/useCounter/demo.vue
Normal file
8
web/vue/src/composables/useCounter/demo.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
</div>
|
||||
</template>
|
||||
81
web/vue/src/composables/useCounter/index.test.ts
Normal file
81
web/vue/src/composables/useCounter/index.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
73
web/vue/src/composables/useCounter/index.ts
Normal file
73
web/vue/src/composables/useCounter/index.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
136
web/vue/src/composables/useEventListener/index.ts
Normal file
136
web/vue/src/composables/useEventListener/index.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
69
web/vue/src/composables/useFocusGuard/index.test.ts
Normal file
69
web/vue/src/composables/useFocusGuard/index.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
40
web/vue/src/composables/useFocusGuard/index.ts
Normal file
40
web/vue/src/composables/useFocusGuard/index.ts
Normal 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);
|
||||
}
|
||||
99
web/vue/src/composables/useInjectionStore/index.test.ts
Normal file
99
web/vue/src/composables/useInjectionStore/index.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
72
web/vue/src/composables/useInjectionStore/index.ts
Normal file
72
web/vue/src/composables/useInjectionStore/index.ts
Normal 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
|
||||
};
|
||||
}
|
||||
50
web/vue/src/composables/useLastChanged/index.test.ts
Normal file
50
web/vue/src/composables/useLastChanged/index.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
38
web/vue/src/composables/useLastChanged/index.ts
Normal file
38
web/vue/src/composables/useLastChanged/index.ts
Normal 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;
|
||||
}
|
||||
27
web/vue/src/composables/useMounted/index.test.ts
Normal file
27
web/vue/src/composables/useMounted/index.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
27
web/vue/src/composables/useMounted/index.ts
Normal file
27
web/vue/src/composables/useMounted/index.ts
Normal 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);
|
||||
}
|
||||
147
web/vue/src/composables/useOffsetPagination/index.test.ts
Normal file
147
web/vue/src/composables/useOffsetPagination/index.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
126
web/vue/src/composables/useOffsetPagination/index.ts
Normal file
126
web/vue/src/composables/useOffsetPagination/index.ts
Normal 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;
|
||||
}
|
||||
75
web/vue/src/composables/useRenderCount/index.test.ts
Normal file
75
web/vue/src/composables/useRenderCount/index.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
29
web/vue/src/composables/useRenderCount/index.ts
Normal file
29
web/vue/src/composables/useRenderCount/index.ts
Normal 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);
|
||||
}
|
||||
100
web/vue/src/composables/useRenderInfo/index.test.ts
Normal file
100
web/vue/src/composables/useRenderInfo/index.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
45
web/vue/src/composables/useRenderInfo/index.ts
Normal file
45
web/vue/src/composables/useRenderInfo/index.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
37
web/vue/src/composables/useSupported/index.test.ts
Normal file
37
web/vue/src/composables/useSupported/index.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
29
web/vue/src/composables/useSupported/index.ts
Normal file
29
web/vue/src/composables/useSupported/index.ts
Normal 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());
|
||||
});
|
||||
}
|
||||
39
web/vue/src/composables/useSyncRefs/index.test.ts
Normal file
39
web/vue/src/composables/useSyncRefs/index.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
46
web/vue/src/composables/useSyncRefs/index.ts
Normal file
46
web/vue/src/composables/useSyncRefs/index.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
51
web/vue/src/composables/useToggle/index.ts
Normal file
51
web/vue/src/composables/useToggle/index.ts
Normal 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
3
web/vue/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './composables';
|
||||
export * from './utils';
|
||||
export * from './types';
|
||||
2
web/vue/src/types/index.ts
Normal file
2
web/vue/src/types/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './resumable';
|
||||
export * from './window';
|
||||
30
web/vue/src/types/resumable.ts
Normal file
30
web/vue/src/types/resumable.ts
Normal 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;
|
||||
}
|
||||
3
web/vue/src/types/window.ts
Normal file
3
web/vue/src/types/window.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { isClient } from '@robonen/platform/multi';
|
||||
|
||||
export const defaultWindow = /* #__PURE__ */ isClient ? window : undefined
|
||||
21
web/vue/src/utils/components.ts
Normal file
21
web/vue/src/utils/components.ts
Normal 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();
|
||||
}
|
||||
13
web/vue/src/utils/error.ts
Normal file
13
web/vue/src/utils/error.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
2
web/vue/src/utils/index.ts
Normal file
2
web/vue/src/utils/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './components';
|
||||
export * from './error';
|
||||
6
web/vue/tsconfig.json
Normal file
6
web/vue/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "@robonen/tsconfig/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user