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

feat(web/vue): add useStorage and useStorageAsync, separate all composables by categories

This commit is contained in:
2026-02-14 21:38:29 +07:00
parent 7dce7ed482
commit 6565fa3de8
64 changed files with 1564 additions and 55 deletions

View File

@@ -0,0 +1,6 @@
export * from './useAppSharedState';
export * from './useAsyncState';
export * from './useContextFactory';
export * from './useCounter';
export * from './useInjectionStore';
export * from './useToggle';

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,209 @@
import { isShallow, nextTick, ref } from 'vue';
import { it, expect, describe, vi, beforeEach, afterEach } from 'vitest';
import { useAsyncState } from '.';
describe('useAsyncState', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('works with a promise', async () => {
const { state, isReady, isLoading, error } = useAsyncState(
Promise.resolve('data'),
'initial',
);
expect(state.value).toBe('initial');
expect(isReady.value).toBe(false);
expect(isLoading.value).toBe(true);
expect(error.value).toBe(null);
await nextTick();
expect(state.value).toBe('data');
expect(isReady.value).toBe(true);
expect(isLoading.value).toBe(false);
expect(error.value).toBe(null);
});
it('works with a function returning a promise', async () => {
const { state, isReady, isLoading, error } = useAsyncState(
() => Promise.resolve('data'),
'initial',
);
expect(state.value).toBe('initial');
expect(isReady.value).toBe(false);
expect(isLoading.value).toBe(true);
expect(error.value).toBe(null);
await nextTick();
expect(state.value).toBe('data');
expect(isReady.value).toBe(true);
expect(isLoading.value).toBe(false);
expect(error.value).toBe(null);
});
it('handles errors', async () => {
const { state, isReady, isLoading, error } = useAsyncState(
Promise.reject(new Error('test-error')),
'initial',
);
expect(state.value).toBe('initial');
expect(isReady.value).toBe(false);
expect(isLoading.value).toBe(true);
expect(error.value).toBe(null);
await nextTick();
expect(state.value).toBe('initial');
expect(isReady.value).toBe(false);
expect(isLoading.value).toBe(false);
expect(error.value).toEqual(new Error('test-error'));
});
it('calls onSuccess callback', async () => {
const onSuccess = vi.fn();
useAsyncState(
Promise.resolve('data'),
'initial',
{ onSuccess },
);
await nextTick();
expect(onSuccess).toHaveBeenCalledWith('data');
});
it('calls onError callback', async () => {
const onError = vi.fn();
const error = new Error('test-error');
useAsyncState(
Promise.reject(error),
'initial',
{ onError },
);
await nextTick();
expect(onError).toHaveBeenCalledWith(error);
});
it('throws error if throwError is true', async () => {
const error = new Error('test-error');
const { executeImmediately } = useAsyncState(
Promise.reject(error),
'initial',
{ immediate: false, throwError: true },
);
await expect(() => executeImmediately()).rejects.toThrow(error);
});
it('resets state on execute if resetOnExecute is true', async () => {
const { state, executeImmediately } = useAsyncState(
(data: string) => Promise.resolve(data),
'initial',
{ immediate: false, resetOnExecute: true },
);
await executeImmediately('new data');
expect(state.value).toBe('new data');
executeImmediately('another data');
expect(state.value).toBe('initial');
});
it('delays execution with default delay', async () => {
const { isLoading, execute } = useAsyncState(
() => Promise.resolve('data'),
'initial',
{ delay: 100, immediate: false },
);
const promise = execute();
expect(isLoading.value).toBe(true);
await vi.advanceTimersByTimeAsync(50);
expect(isLoading.value).toBe(true);
await vi.advanceTimersByTimeAsync(50);
await promise;
expect(isLoading.value).toBe(false);
});
it('is awaitable', async () => {
const { state } = await useAsyncState(
Promise.resolve('data'),
'initial',
);
expect(state.value).toBe('data');
});
it('works with executeImmediately', async () => {
const { state, isReady, isLoading, error, executeImmediately } = useAsyncState(
() => Promise.resolve('data'),
'initial',
{ immediate: false },
);
executeImmediately();
expect(state.value).toBe('initial');
expect(isLoading.value).toBe(true);
expect(isReady.value).toBe(false);
expect(error.value).toBe(null);
await nextTick();
expect(state.value).toBe('data');
expect(isReady.value).toBe(true);
expect(isLoading.value).toBe(false);
expect(error.value).toBe(null);
});
it('passes params to the function', async () => {
const promiseFn = vi.fn((...args: any[]) => Promise.resolve(args.join(' ')));
const { executeImmediately } = useAsyncState(
promiseFn,
'initial',
{ immediate: false },
);
await executeImmediately('hello', 'world');
expect(promiseFn).toHaveBeenCalledWith('hello', 'world');
});
it('uses shallowRef by default', async () => {
const { state } = await useAsyncState(
Promise.resolve({ a: 1 }),
{ a: 0 },
);
expect(state.value.a).toBe(1);
expect(isShallow(state)).toBe(true);
});
it('uses ref when shallow is false', async () => {
const { state } = await useAsyncState(
Promise.resolve({ a: ref(1) }),
{ a: ref(0) },
{ shallow: false },
);
expect(state.value.a).toBe(1);
expect(isShallow(state)).toBe(false);
});
});

View File

@@ -0,0 +1,124 @@
import { ref, shallowRef, watch, type Ref, type ShallowRef, type UnwrapRef } from 'vue';
import { isFunction, sleep } from '@robonen/stdlib';
export interface UseAsyncStateOptions<Shallow extends boolean, Data = any> {
delay?: number;
shallow?: Shallow;
immediate?: boolean;
resetOnExecute?: boolean;
throwError?: boolean;
onError?: (error: unknown) => void;
onSuccess?: (data: Data) => void;
}
export interface UseAsyncStateReturnBase<Data, Params extends any[], Shallow extends boolean> {
state: Shallow extends true ? ShallowRef<Data> : Ref<UnwrapRef<Data>>;
isLoading: Ref<boolean>;
isReady: Ref<boolean>;
error: Ref<unknown | null>;
execute: (delay?: number, ...params: Params) => Promise<Data>;
executeImmediately: (...params: Params) => Promise<Data>;
}
export type UseAsyncStateReturn<Data, Params extends any[], Shallow extends boolean> =
& UseAsyncStateReturnBase<Data, Params, Shallow>
& PromiseLike<UseAsyncStateReturnBase<Data, Params, Shallow>>;
/**
* @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>,
): UseAsyncStateReturn<Data, Params, Shallow> {
const {
delay = 0,
shallow = true,
immediate = true,
resetOnExecute = false,
throwError = false,
onError,
onSuccess,
} = options ?? {};
const state = shallow ? shallowRef(initialState) : ref(initialState);
const error = ref<unknown | null>(null);
const isLoading = ref(false);
const isReady = ref(false);
const execute = async (actualDelay = delay, ...params: any[]) => {
if (resetOnExecute)
state.value = initialState;
isLoading.value = true;
isReady.value = false;
error.value = null;
if (actualDelay > 0)
await sleep(actualDelay);
const promise = isFunction(maybePromise) ? maybePromise(...params as Params) : maybePromise;
try {
const data = await promise;
state.value = data;
isReady.value = true;
onSuccess?.(data);
}
catch (e: unknown) {
error.value = e;
onError?.(e);
if (throwError)
throw e;
}
finally {
isLoading.value = false;
}
return state.value as Data;
};
const executeImmediately = (...params: Params) => {
return execute(0, ...params);
};
if (immediate)
execute();
const shell = {
state: state as Shallow extends true ? ShallowRef<Data> : Ref<UnwrapRef<Data>>,
isLoading,
isReady,
error,
execute,
executeImmediately,
};
function waitResolve() {
return new Promise<UseAsyncStateReturnBase<Data, Params, Shallow>>((resolve, reject) => {
watch(
isLoading,
(loading) => {
if (loading === false)
error.value ? reject(error.value) : resolve(shell);
},
{
immediate: true,
once: true,
flush: 'sync',
},
);
});
}
return {
...shell,
then(onFulfilled, onRejected) {
return waitResolve().then(onFulfilled, onRejected);
},
}
}

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 as vueInject, provide as vueProvide, type InjectionKey, type App } from 'vue';
import { VueToolsError } from '@/utils';
/**
* @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 inject = <Fallback extends ContextValue = ContextValue>(fallback?: Fallback) => {
const context = vueInject(injectionKey, fallback);
if (context !== undefined)
return context;
throw new VueToolsError(`useContextFactory: '${name}' context is not provided`);
};
const provide = (context: ContextValue) => {
vueProvide(injectionKey, context);
return context;
};
const appProvide = (app: App) => (context: ContextValue) => {
app.provide(injectionKey, context);
return context;
};
return {
inject,
provide,
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 State
* @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,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,73 @@
import { useContextFactory } from '../useContextFactory';
import type { App, InjectionKey } from 'vue';
export interface useInjectionStoreOptions<Return> {
injectionName?: string;
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 ctx = useContextFactory<Return>(options?.injectionName ?? stateFactory.name ?? 'InjectionStore');
const useProvidingState = (...args: Args) => {
const state = stateFactory(...args);
ctx.provide(state);
return state;
};
const useAppProvidingState = (app: App) => (...args: Args) => {
const state = stateFactory(...args);
ctx.appProvide(app)(state);
return state;
};
const useInjectedState = () => ctx.inject(options?.defaultValue);
return {
useProvidingState,
useAppProvidingState,
useInjectedState
};
}

View File

@@ -0,0 +1,110 @@
import { it, expect, describe } from 'vitest';
import { ref } from 'vue';
import { useToggle } from '.';
describe('useToggle', () => {
it('initialize with false by default', () => {
const { value } = useToggle();
expect(value.value).toBe(false);
});
it('initialize with the provided initial value', () => {
const { value } = useToggle(true);
expect(value.value).toBe(true);
});
it('initialize with the provided initial value from a ref', () => {
const { value } = useToggle(ref(true));
expect(value.value).toBe(true);
});
it('toggle from false to true', () => {
const { value, toggle } = useToggle(false);
toggle();
expect(value.value).toBe(true);
});
it('toggle from true to false', () => {
const { value, toggle } = useToggle(true);
toggle();
expect(value.value).toBe(false);
});
it('toggle multiple times', () => {
const { value, toggle } = useToggle(false);
toggle();
expect(value.value).toBe(true);
toggle();
expect(value.value).toBe(false);
toggle();
expect(value.value).toBe(true);
});
it('toggle returns the new value', () => {
const { toggle } = useToggle(false);
expect(toggle()).toBe(true);
expect(toggle()).toBe(false);
});
it('set a specific value via toggle', () => {
const { value, toggle } = useToggle(false);
toggle(true);
expect(value.value).toBe(true);
toggle(true);
expect(value.value).toBe(true);
});
it('use custom truthy and falsy values', () => {
const { value, toggle } = useToggle('off', {
truthyValue: 'on',
falsyValue: 'off',
});
expect(value.value).toBe('off');
toggle();
expect(value.value).toBe('on');
toggle();
expect(value.value).toBe('off');
});
it('set a specific custom value via toggle', () => {
const { value, toggle } = useToggle('off', {
truthyValue: 'on',
falsyValue: 'off',
});
toggle('on');
expect(value.value).toBe('on');
toggle('on');
expect(value.value).toBe('on');
});
it('use ref-based truthy and falsy values', () => {
const truthy = ref('yes');
const falsy = ref('no');
const { value, toggle } = useToggle('no', {
truthyValue: truthy,
falsyValue: falsy,
});
expect(value.value).toBe('no');
toggle();
expect(value.value).toBe('yes');
toggle();
expect(value.value).toBe('no');
});
it('use getter-based truthy and falsy values', () => {
const { value, toggle } = useToggle(0, {
truthyValue: () => 1,
falsyValue: () => 0,
});
expect(value.value).toBe(0);
toggle();
expect(value.value).toBe(1);
toggle();
expect(value.value).toBe(0);
});
});

View File

@@ -0,0 +1,56 @@
import { ref, toValue, type MaybeRefOrGetter, type MaybeRef, type Ref } from 'vue';
export interface UseToggleOptions<Truthy, Falsy> {
truthyValue?: MaybeRefOrGetter<Truthy>,
falsyValue?: MaybeRefOrGetter<Falsy>,
}
export interface UseToggleReturn<Truthy, Falsy> {
value: Ref<Truthy | Falsy>;
toggle: (value?: Truthy | Falsy) => Truthy | Falsy;
}
/**
* @name useToggle
* @category State
* @description A composable that provides a boolean toggle with customizable truthy/falsy values
*
* @param {MaybeRef<Truthy | Falsy>} [initialValue=false] The initial value
* @param {UseToggleOptions<Truthy, Falsy>} [options={}] Options for custom truthy/falsy values
* @returns {UseToggleReturn<Truthy, Falsy>} The toggle state and function
*
* @example
* const { value, toggle } = useToggle();
*
* @example
* const { value, toggle } = useToggle(false, { truthyValue: 'on', falsyValue: 'off' });
*
* @since 0.0.1
*/
export function useToggle<Truthy = true, Falsy = false>(
initialValue: MaybeRef<Truthy | Falsy> = false as Truthy | Falsy,
options: UseToggleOptions<Truthy, Falsy> = {},
): UseToggleReturn<Truthy, Falsy> {
const {
truthyValue = true as Truthy,
falsyValue = false as Falsy,
} = options;
const value = ref(initialValue) as Ref<Truthy | Falsy>;
const toggle = (newValue?: Truthy | Falsy) => {
if (newValue !== undefined) {
value.value = newValue;
return value.value;
}
const truthy = toValue(truthyValue);
const falsy = toValue(falsyValue);
value.value = value.value === truthy ? falsy : truthy;
return value.value;
};
return { value, toggle };
}