mirror of
https://github.com/robonen/tools.git
synced 2026-03-20 10:54:44 +00:00
Merge pull request #123 from robonen/vue-composable-categories
feat(web/vue): add useStorage and useStorageAsync, separate all composables by categories
This commit is contained in:
8
core/platform/vitest.config.ts
Normal file
8
core/platform/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
7
core/stdlib/vitest.config.ts
Normal file
7
core/stdlib/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -3,16 +3,10 @@ import { defineConfig } from 'vitest/config';
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
projects: [
|
projects: [
|
||||||
{
|
'core/stdlib/vitest.config.ts',
|
||||||
extends: true,
|
'core/platform/vitest.config.ts',
|
||||||
test: {
|
'web/vue/vitest.config.ts',
|
||||||
typecheck: {
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
environment: 'jsdom',
|
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: 'v8',
|
provider: 'v8',
|
||||||
include: ['core/*', 'web/*'],
|
include: ['core/*', 'web/*'],
|
||||||
|
|||||||
3
web/vue/src/composables/browser/index.ts
Normal file
3
web/vue/src/composables/browser/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './useEventListener';
|
||||||
|
export * from './useFocusGuard';
|
||||||
|
export * from './useSupported';
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { isArray, isString, noop, type Arrayable, type VoidFunction } from '@robonen/stdlib';
|
import { isArray, isString, noop, type Arrayable, type VoidFunction } from '@robonen/stdlib';
|
||||||
import type { MaybeRefOrGetter } from 'vue';
|
import type { MaybeRefOrGetter } from 'vue';
|
||||||
import { defaultWindow } from '../..';
|
import { defaultWindow } from '@/types';
|
||||||
|
|
||||||
// TODO: wip
|
// TODO: wip
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ export type ElementEventName = keyof HTMLElementEventMap;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @name useEventListener
|
* @name useEventListener
|
||||||
* @category Elements
|
* @category Browser
|
||||||
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
|
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
|
||||||
*
|
*
|
||||||
* Overload 1: Omitted window target
|
* Overload 1: Omitted window target
|
||||||
@@ -32,7 +32,7 @@ export function useEventListener<E extends WindowEventName>(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @name useEventListener
|
* @name useEventListener
|
||||||
* @category Elements
|
* @category Browser
|
||||||
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
|
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
|
||||||
*
|
*
|
||||||
* Overload 2: Explicit window target
|
* Overload 2: Explicit window target
|
||||||
@@ -46,7 +46,7 @@ export function useEventListener<E extends WindowEventName>(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @name useEventListener
|
* @name useEventListener
|
||||||
* @category Elements
|
* @category Browser
|
||||||
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
|
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
|
||||||
*
|
*
|
||||||
* Overload 3: Explicit document target
|
* Overload 3: Explicit document target
|
||||||
@@ -60,7 +60,7 @@ export function useEventListener<E extends DocumentEventName>(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @name useEventListener
|
* @name useEventListener
|
||||||
* @category Elements
|
* @category Browser
|
||||||
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
|
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
|
||||||
*
|
*
|
||||||
* Overload 4: Explicit HTMLElement target
|
* Overload 4: Explicit HTMLElement target
|
||||||
@@ -74,7 +74,7 @@ export function useEventListener<E extends ElementEventName>(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @name useEventListener
|
* @name useEventListener
|
||||||
* @category Elements
|
* @category Browser
|
||||||
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
|
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
|
||||||
*
|
*
|
||||||
* Overload 5: Custom target with inferred event type
|
* Overload 5: Custom target with inferred event type
|
||||||
@@ -88,7 +88,7 @@ export function useEventListener<Names extends string, EventType = Event>(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @name useEventListener
|
* @name useEventListener
|
||||||
* @category Elements
|
* @category Browser
|
||||||
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
|
* @description Registers an event listener using the `addEventListener` on mounted and removes it automatically on unmounted
|
||||||
*
|
*
|
||||||
* Overload 6: Custom event target fallback
|
* Overload 6: Custom event target fallback
|
||||||
@@ -6,7 +6,7 @@ let counter = 0;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @name useFocusGuard
|
* @name useFocusGuard
|
||||||
* @category Utilities
|
* @category Browser
|
||||||
* @description Adds a pair of focus guards at the boundaries of the DOM tree to ensure consistent focus behavior
|
* @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
|
* @param {string} [namespace] - A namespace to group the focus guards
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useMounted } from '../useMounted';
|
import { useMounted } from '@/composables/lifecycle/useMounted';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @name useSupported
|
* @name useSupported
|
||||||
* @category Utilities
|
* @category Browser
|
||||||
* @description SSR-friendly way to check if a feature is supported
|
* @description SSR-friendly way to check if a feature is supported
|
||||||
*
|
*
|
||||||
* @param {Function} feature The feature to check for support
|
* @param {Function} feature The feature to check for support
|
||||||
3
web/vue/src/composables/component/index.ts
Normal file
3
web/vue/src/composables/component/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './unrefElement';
|
||||||
|
export * from './useRenderCount';
|
||||||
|
export * from './useRenderInfo';
|
||||||
@@ -11,7 +11,7 @@ export type UnRefElementReturn<T extends MaybeElement = MaybeElement> = T extend
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @name unrefElement
|
* @name unrefElement
|
||||||
* @category Components
|
* @category Component
|
||||||
* @description Unwraps a Vue element reference to get the underlying instance or DOM element.
|
* @description Unwraps a Vue element reference to get the underlying instance or DOM element.
|
||||||
*
|
*
|
||||||
* @param {MaybeComputedElementRef<El>} elRef - The element reference to unwrap.
|
* @param {MaybeComputedElementRef<El>} elRef - The element reference to unwrap.
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { onMounted, onUpdated, readonly, type ComponentInternalInstance } from 'vue';
|
import { onMounted, onUpdated, readonly, type ComponentInternalInstance } from 'vue';
|
||||||
import { useCounter } from '../useCounter';
|
import { useCounter } from '@/composables/state/useCounter';
|
||||||
import { getLifeCycleTarger } from '../..';
|
import { getLifeCycleTarger } from '@/utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @name useRenderCount
|
* @name useRenderCount
|
||||||
* @category Components
|
* @category Component
|
||||||
* @description Returns the number of times the component has been rendered into the DOM
|
* @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
|
* @param {ComponentInternalInstance} [instance] The component instance to track the render count for
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { timestamp } from '@robonen/stdlib';
|
import { timestamp } from '@robonen/stdlib';
|
||||||
import { onBeforeMount, onBeforeUpdate, onMounted, onUpdated, readonly, ref, type ComponentInternalInstance } from 'vue';
|
import { onBeforeMount, onBeforeUpdate, onMounted, onUpdated, readonly, ref, type ComponentInternalInstance } from 'vue';
|
||||||
import { useRenderCount } from '../useRenderCount';
|
import { useRenderCount } from '../useRenderCount';
|
||||||
import { getLifeCycleTarger } from '../..';
|
import { getLifeCycleTarger } from '@/utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @name useRenderInfo
|
* @name useRenderInfo
|
||||||
* @category Components
|
* @category Component
|
||||||
* @description Returns information about the component's render count and the last time it was rendered
|
* @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
|
* @param {ComponentInternalInstance} [instance] The component instance to track the render count for
|
||||||
@@ -1,19 +1,8 @@
|
|||||||
export * from './tryOnBeforeMount';
|
export * from './browser';
|
||||||
export * from './tryOnMounted';
|
export * from './component';
|
||||||
export * from './tryOnScopeDispose';
|
export * from './lifecycle';
|
||||||
export * from './unrefElement';
|
export * from './math';
|
||||||
export * from './useAppSharedState';
|
export * from './reactivity';
|
||||||
export * from './useAsyncState';
|
export * from './state';
|
||||||
export * from './useCached';
|
export * from './storage';
|
||||||
export * from './useClamp';
|
export * from './utilities';
|
||||||
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';
|
|
||||||
|
|||||||
4
web/vue/src/composables/lifecycle/index.ts
Normal file
4
web/vue/src/composables/lifecycle/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './tryOnBeforeMount';
|
||||||
|
export * from './tryOnMounted';
|
||||||
|
export * from './tryOnScopeDispose';
|
||||||
|
export * from './useMounted';
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { onBeforeMount, nextTick, type ComponentInternalInstance } from 'vue';
|
import { onBeforeMount, nextTick, type ComponentInternalInstance } from 'vue';
|
||||||
import { getLifeCycleTarger } from '../..';
|
import { getLifeCycleTarger } from '@/utils';
|
||||||
import type { VoidFunction } from '@robonen/stdlib';
|
import type { VoidFunction } from '@robonen/stdlib';
|
||||||
|
|
||||||
// TODO: test
|
// TODO: test
|
||||||
@@ -11,7 +11,7 @@ export interface TryOnBeforeMountOptions {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @name tryOnBeforeMount
|
* @name tryOnBeforeMount
|
||||||
* @category Components
|
* @category Lifecycle
|
||||||
* @description Call onBeforeMount if it's inside a component lifecycle hook, otherwise just calls it
|
* @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 {VoidFunction} fn - The function to run on before mount.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { onMounted, nextTick, type ComponentInternalInstance } from 'vue';
|
import { onMounted, nextTick, type ComponentInternalInstance } from 'vue';
|
||||||
import { getLifeCycleTarger } from '../..';
|
import { getLifeCycleTarger } from '@/utils';
|
||||||
import type { VoidFunction } from '@robonen/stdlib';
|
import type { VoidFunction } from '@robonen/stdlib';
|
||||||
|
|
||||||
// TODO: tests
|
// TODO: tests
|
||||||
@@ -11,7 +11,7 @@ export interface TryOnMountedOptions {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @name tryOnMounted
|
* @name tryOnMounted
|
||||||
* @category Components
|
* @category Lifecycle
|
||||||
* @description Call onMounted if it's inside a component lifecycle hook, otherwise just calls it
|
* @description Call onMounted if it's inside a component lifecycle hook, otherwise just calls it
|
||||||
*
|
*
|
||||||
* @param {VoidFunction} fn The function to call
|
* @param {VoidFunction} fn The function to call
|
||||||
@@ -3,7 +3,7 @@ import { getCurrentScope, onScopeDispose } from 'vue';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @name tryOnScopeDispose
|
* @name tryOnScopeDispose
|
||||||
* @category Components
|
* @category Lifecycle
|
||||||
* @description A composable that will run a callback when the scope is disposed or do nothing if the scope isn't available.
|
* @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.
|
* @param {VoidFunction} callback - The callback to run when the scope is disposed.
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { onMounted, readonly, ref, type ComponentInternalInstance } from 'vue';
|
import { onMounted, readonly, ref, type ComponentInternalInstance } from 'vue';
|
||||||
import { getLifeCycleTarger } from '../..';
|
import { getLifeCycleTarger } from '@/utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @name useMounted
|
* @name useMounted
|
||||||
* @category Components
|
* @category Lifecycle
|
||||||
* @description Returns a ref that tracks the mounted state of the component (doesn't track the unmounted state)
|
* @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
|
* @param {ComponentInternalInstance} [instance] The component instance to track the mounted state for
|
||||||
1
web/vue/src/composables/math/index.ts
Normal file
1
web/vue/src/composables/math/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './useClamp';
|
||||||
41
web/vue/src/composables/reactivity/broadcastedRef/index.ts
Normal file
41
web/vue/src/composables/reactivity/broadcastedRef/index.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { customRef, onScopeDispose } from 'vue';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name broadcastedRef
|
||||||
|
* @category Reactivity
|
||||||
|
* @description Creates a custom ref that syncs its value across browser tabs via the BroadcastChannel API
|
||||||
|
*
|
||||||
|
* @param {string} key The channel key to use for broadcasting
|
||||||
|
* @param {T} initialValue The initial value of the ref
|
||||||
|
* @returns {Ref<T>} A custom ref that broadcasts value changes across tabs
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const count = broadcastedRef('counter', 0);
|
||||||
|
*
|
||||||
|
* @since 0.0.1
|
||||||
|
*/
|
||||||
|
export function broadcastedRef<T>(key: string, initialValue: T) {
|
||||||
|
const channel = new BroadcastChannel(key);
|
||||||
|
|
||||||
|
onScopeDispose(channel.close);
|
||||||
|
|
||||||
|
return customRef<T>((track, trigger) => {
|
||||||
|
channel.onmessage = (event) => {
|
||||||
|
track();
|
||||||
|
return event.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
channel.postMessage(initialValue);
|
||||||
|
|
||||||
|
return {
|
||||||
|
get() {
|
||||||
|
return initialValue;
|
||||||
|
},
|
||||||
|
set(newValue: T) {
|
||||||
|
initialValue = newValue;
|
||||||
|
channel.postMessage(newValue);
|
||||||
|
trigger();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
4
web/vue/src/composables/reactivity/index.ts
Normal file
4
web/vue/src/composables/reactivity/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './broadcastedRef';
|
||||||
|
export * from './useCached';
|
||||||
|
export * from './useLastChanged';
|
||||||
|
export * from './useSyncRefs';
|
||||||
@@ -10,7 +10,7 @@ export interface UseLastChangedOptions<
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @name useLastChanged
|
* @name useLastChanged
|
||||||
* @category State
|
* @category Reactivity
|
||||||
* @description Records the last time a value changed
|
* @description Records the last time a value changed
|
||||||
*
|
*
|
||||||
* @param {WatchSource} source The value to track
|
* @param {WatchSource} source The value to track
|
||||||
6
web/vue/src/composables/state/index.ts
Normal file
6
web/vue/src/composables/state/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export * from './useAppSharedState';
|
||||||
|
export * from './useAsyncState';
|
||||||
|
export * from './useContextFactory';
|
||||||
|
export * from './useCounter';
|
||||||
|
export * from './useInjectionStore';
|
||||||
|
export * from './useToggle';
|
||||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
|
|||||||
import { defineComponent } from 'vue';
|
import { defineComponent } from 'vue';
|
||||||
import { useContextFactory } from '.';
|
import { useContextFactory } from '.';
|
||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
import { VueToolsError } from '../../utils';
|
import { VueToolsError } from '@/utils';
|
||||||
|
|
||||||
function testFactory<Data>(
|
function testFactory<Data>(
|
||||||
data: Data,
|
data: Data,
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { inject as vueInject, provide as vueProvide, type InjectionKey, type App } from 'vue';
|
import { inject as vueInject, provide as vueProvide, type InjectionKey, type App } from 'vue';
|
||||||
import { VueToolsError } from '../..';
|
import { VueToolsError } from '@/utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @name useContextFactory
|
* @name useContextFactory
|
||||||
@@ -17,7 +17,7 @@ export interface UseConterReturn {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @name useCounter
|
* @name useCounter
|
||||||
* @category Utilities
|
* @category State
|
||||||
* @description A composable that provides a counter with increment, decrement, set, get, and reset functions
|
* @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 {MaybeRef<number>} [initialValue=0] The initial value of the counter
|
||||||
110
web/vue/src/composables/state/useToggle/index.test.ts
Normal file
110
web/vue/src/composables/state/useToggle/index.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,19 +1,36 @@
|
|||||||
import { isRef, ref, toValue, type MaybeRefOrGetter, type MaybeRef, type Ref } from 'vue';
|
import { ref, toValue, type MaybeRefOrGetter, type MaybeRef, type Ref } from 'vue';
|
||||||
|
|
||||||
export interface UseToggleOptions<Truthy, Falsy> {
|
export interface UseToggleOptions<Truthy, Falsy> {
|
||||||
truthyValue?: MaybeRefOrGetter<Truthy>,
|
truthyValue?: MaybeRefOrGetter<Truthy>,
|
||||||
falsyValue?: MaybeRefOrGetter<Falsy>,
|
falsyValue?: MaybeRefOrGetter<Falsy>,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useToggle<Truthy = true, Falsy = false>(
|
export interface UseToggleReturn<Truthy, Falsy> {
|
||||||
initialValue?: MaybeRef<Truthy | Falsy>,
|
value: Ref<Truthy | Falsy>;
|
||||||
options?: UseToggleOptions<Truthy, Falsy>,
|
toggle: (value?: Truthy | Falsy) => 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>(
|
export function useToggle<Truthy = true, Falsy = false>(
|
||||||
initialValue: MaybeRef<Truthy | Falsy> = false as Truthy | Falsy,
|
initialValue: MaybeRef<Truthy | Falsy> = false as Truthy | Falsy,
|
||||||
options: UseToggleOptions<Truthy, Falsy> = {},
|
options: UseToggleOptions<Truthy, Falsy> = {},
|
||||||
) {
|
): UseToggleReturn<Truthy, Falsy> {
|
||||||
const {
|
const {
|
||||||
truthyValue = true as Truthy,
|
truthyValue = true as Truthy,
|
||||||
falsyValue = false as Falsy,
|
falsyValue = false as Falsy,
|
||||||
4
web/vue/src/composables/storage/index.ts
Normal file
4
web/vue/src/composables/storage/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './useLocalStorage';
|
||||||
|
export * from './useSessionStorage';
|
||||||
|
export * from './useStorage';
|
||||||
|
export * from './useStorageAsync';
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { nextTick } from 'vue';
|
||||||
|
import { useLocalStorage } from '.';
|
||||||
|
|
||||||
|
describe('useLocalStorage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores and reads a string via localStorage', async () => {
|
||||||
|
const state = useLocalStorage<string>('ls-string', 'hello');
|
||||||
|
|
||||||
|
expect(state.value).toBe('hello');
|
||||||
|
expect(localStorage.getItem('ls-string')).toBe('hello');
|
||||||
|
|
||||||
|
state.value = 'world';
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(localStorage.getItem('ls-string')).toBe('world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores and reads a number', async () => {
|
||||||
|
const state = useLocalStorage<number>('ls-number', 42);
|
||||||
|
|
||||||
|
expect(state.value).toBe(42);
|
||||||
|
|
||||||
|
state.value = 100;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(localStorage.getItem('ls-number')).toBe('100');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores and reads an object', async () => {
|
||||||
|
const state = useLocalStorage('ls-obj', { a: 1 });
|
||||||
|
|
||||||
|
expect(state.value).toEqual({ a: 1 });
|
||||||
|
|
||||||
|
state.value = { a: 2 };
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(JSON.parse(localStorage.getItem('ls-obj')!)).toEqual({ a: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads existing value from localStorage on init', () => {
|
||||||
|
localStorage.setItem('ls-existing', '"stored"');
|
||||||
|
|
||||||
|
const state = useLocalStorage('ls-existing', 'default', {
|
||||||
|
serializer: { read: (v) => JSON.parse(v), write: (v) => JSON.stringify(v) },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.value).toBe('stored');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes from localStorage when set to null', async () => {
|
||||||
|
const state = useLocalStorage<string | null>('ls-null', 'value');
|
||||||
|
expect(localStorage.getItem('ls-null')).toBe('value');
|
||||||
|
|
||||||
|
state.value = null;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(localStorage.getItem('ls-null')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes options through to useStorage', () => {
|
||||||
|
const state = useLocalStorage<string>('ls-no-write', 'default', {
|
||||||
|
writeDefaults: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.value).toBe('default');
|
||||||
|
expect(localStorage.getItem('ls-no-write')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
40
web/vue/src/composables/storage/useLocalStorage/index.ts
Normal file
40
web/vue/src/composables/storage/useLocalStorage/index.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { MaybeRefOrGetter, Ref } from 'vue';
|
||||||
|
import { defaultWindow } from '@/types';
|
||||||
|
import { VueToolsError } from '@/utils/error';
|
||||||
|
import { useStorage, type UseStorageOptions } from '../useStorage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useLocalStorage
|
||||||
|
* @category Storage
|
||||||
|
* @description Reactive localStorage binding — creates a ref synced with `window.localStorage`
|
||||||
|
*
|
||||||
|
* @param {string} key The storage key
|
||||||
|
* @param {MaybeRefOrGetter<T>} initialValue The initial/default value
|
||||||
|
* @param {UseStorageOptions<T>} [options={}] Options
|
||||||
|
* @returns {Ref<T>} A reactive ref synced with localStorage
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const count = useLocalStorage('my-count', 0);
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const state = useLocalStorage('my-state', { hello: 'world' });
|
||||||
|
*
|
||||||
|
* @since 0.0.12
|
||||||
|
*/
|
||||||
|
export function useLocalStorage<T extends string>(key: string, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): Ref<T>;
|
||||||
|
export function useLocalStorage<T extends number>(key: string, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): Ref<T>;
|
||||||
|
export function useLocalStorage<T extends boolean>(key: string, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): Ref<T>;
|
||||||
|
export function useLocalStorage<T>(key: string, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): Ref<T>;
|
||||||
|
export function useLocalStorage<T = unknown>(key: string, initialValue: MaybeRefOrGetter<null>, options?: UseStorageOptions<T>): Ref<T>;
|
||||||
|
export function useLocalStorage<T>(
|
||||||
|
key: string,
|
||||||
|
initialValue: MaybeRefOrGetter<T>,
|
||||||
|
options: UseStorageOptions<T> = {},
|
||||||
|
): Ref<T> {
|
||||||
|
const storage = defaultWindow?.localStorage;
|
||||||
|
|
||||||
|
if (!storage)
|
||||||
|
throw new VueToolsError('useLocalStorage: localStorage is not available');
|
||||||
|
|
||||||
|
return useStorage(key, initialValue, storage, options);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { nextTick } from 'vue';
|
||||||
|
import { useSessionStorage } from '.';
|
||||||
|
|
||||||
|
describe('useSessionStorage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores and reads a string via sessionStorage', async () => {
|
||||||
|
const state = useSessionStorage<string>('ss-string', 'hello');
|
||||||
|
|
||||||
|
expect(state.value).toBe('hello');
|
||||||
|
expect(sessionStorage.getItem('ss-string')).toBe('hello');
|
||||||
|
|
||||||
|
state.value = 'world';
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(sessionStorage.getItem('ss-string')).toBe('world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores and reads a number', async () => {
|
||||||
|
const state = useSessionStorage<number>('ss-number', 42);
|
||||||
|
|
||||||
|
expect(state.value).toBe(42);
|
||||||
|
|
||||||
|
state.value = 100;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(sessionStorage.getItem('ss-number')).toBe('100');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores and reads an object', async () => {
|
||||||
|
const state = useSessionStorage('ss-obj', { a: 1 });
|
||||||
|
|
||||||
|
expect(state.value).toEqual({ a: 1 });
|
||||||
|
|
||||||
|
state.value = { a: 2 };
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(JSON.parse(sessionStorage.getItem('ss-obj')!)).toEqual({ a: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads existing value from sessionStorage on init', () => {
|
||||||
|
sessionStorage.setItem('ss-existing', '"stored"');
|
||||||
|
|
||||||
|
const state = useSessionStorage('ss-existing', 'default', {
|
||||||
|
serializer: { read: (v) => JSON.parse(v), write: (v) => JSON.stringify(v) },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.value).toBe('stored');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes from sessionStorage when set to null', async () => {
|
||||||
|
const state = useSessionStorage<string | null>('ss-null', 'value');
|
||||||
|
expect(sessionStorage.getItem('ss-null')).toBe('value');
|
||||||
|
|
||||||
|
state.value = null;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(sessionStorage.getItem('ss-null')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes options through to useStorage', () => {
|
||||||
|
const state = useSessionStorage<string>('ss-no-write', 'default', {
|
||||||
|
writeDefaults: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.value).toBe('default');
|
||||||
|
expect(sessionStorage.getItem('ss-no-write')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
40
web/vue/src/composables/storage/useSessionStorage/index.ts
Normal file
40
web/vue/src/composables/storage/useSessionStorage/index.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { MaybeRefOrGetter, Ref } from 'vue';
|
||||||
|
import { defaultWindow } from '@/types';
|
||||||
|
import { VueToolsError } from '@/utils/error';
|
||||||
|
import { useStorage, type UseStorageOptions } from '../useStorage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useSessionStorage
|
||||||
|
* @category Storage
|
||||||
|
* @description Reactive sessionStorage binding — creates a ref synced with `window.sessionStorage`
|
||||||
|
*
|
||||||
|
* @param {string} key The storage key
|
||||||
|
* @param {MaybeRefOrGetter<T>} initialValue The initial/default value
|
||||||
|
* @param {UseStorageOptions<T>} [options={}] Options
|
||||||
|
* @returns {Ref<T>} A reactive ref synced with sessionStorage
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const count = useSessionStorage('my-count', 0);
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const state = useSessionStorage('my-state', { hello: 'world' });
|
||||||
|
*
|
||||||
|
* @since 0.0.12
|
||||||
|
*/
|
||||||
|
export function useSessionStorage<T extends string>(key: string, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): Ref<T>;
|
||||||
|
export function useSessionStorage<T extends number>(key: string, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): Ref<T>;
|
||||||
|
export function useSessionStorage<T extends boolean>(key: string, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): Ref<T>;
|
||||||
|
export function useSessionStorage<T>(key: string, initialValue: MaybeRefOrGetter<T>, options?: UseStorageOptions<T>): Ref<T>;
|
||||||
|
export function useSessionStorage<T = unknown>(key: string, initialValue: MaybeRefOrGetter<null>, options?: UseStorageOptions<T>): Ref<T>;
|
||||||
|
export function useSessionStorage<T>(
|
||||||
|
key: string,
|
||||||
|
initialValue: MaybeRefOrGetter<T>,
|
||||||
|
options: UseStorageOptions<T> = {},
|
||||||
|
): Ref<T> {
|
||||||
|
const storage = defaultWindow?.sessionStorage;
|
||||||
|
|
||||||
|
if (!storage)
|
||||||
|
throw new VueToolsError('useSessionStorage: sessionStorage is not available');
|
||||||
|
|
||||||
|
return useStorage(key, initialValue, storage, options);
|
||||||
|
}
|
||||||
318
web/vue/src/composables/storage/useStorage/index.test.ts
Normal file
318
web/vue/src/composables/storage/useStorage/index.test.ts
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { nextTick } from 'vue';
|
||||||
|
import { useStorage, StorageSerializers, type StorageLike } from '.';
|
||||||
|
|
||||||
|
function createMockStorage(): StorageLike & { store: Map<string, string> } {
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
getItem: (key: string) => store.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => store.set(key, value),
|
||||||
|
removeItem: (key: string) => store.delete(key),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useStorage', () => {
|
||||||
|
// --- Basic types ---
|
||||||
|
|
||||||
|
it('stores and reads a string', async () => {
|
||||||
|
const storage = createMockStorage();
|
||||||
|
const state = useStorage<string>('test-string', 'hello', storage);
|
||||||
|
|
||||||
|
expect(state.value).toBe('hello');
|
||||||
|
expect(storage.getItem('test-string')).toBe('hello');
|
||||||
|
|
||||||
|
state.value = 'world';
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(storage.getItem('test-string')).toBe('world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores and reads a number', async () => {
|
||||||
|
const storage = createMockStorage();
|
||||||
|
const state = useStorage<number>('test-number', 42, storage);
|
||||||
|
|
||||||
|
expect(state.value).toBe(42);
|
||||||
|
|
||||||
|
state.value = 100;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(storage.getItem('test-number')).toBe('100');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores and reads a boolean', async () => {
|
||||||
|
const storage = createMockStorage();
|
||||||
|
const state = useStorage<boolean>('test-bool', true, storage);
|
||||||
|
|
||||||
|
expect(state.value).toBe(true);
|
||||||
|
|
||||||
|
state.value = false;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(storage.getItem('test-bool')).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores and reads an object', async () => {
|
||||||
|
const storage = createMockStorage();
|
||||||
|
const state = useStorage('test-obj', { a: 1, b: 'two' }, storage);
|
||||||
|
|
||||||
|
expect(state.value).toEqual({ a: 1, b: 'two' });
|
||||||
|
|
||||||
|
state.value = { a: 2, b: 'three' };
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(JSON.parse(storage.getItem('test-obj')!)).toEqual({ a: 2, b: 'three' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Reads existing value from storage ---
|
||||||
|
|
||||||
|
it('reads existing value from storage on init', () => {
|
||||||
|
const storage = createMockStorage();
|
||||||
|
storage.store.set('existing', '"stored-value"');
|
||||||
|
|
||||||
|
const state = useStorage('existing', 'default', storage, {
|
||||||
|
serializer: StorageSerializers.object,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.value).toBe('stored-value');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Removes item when set to null ---
|
||||||
|
|
||||||
|
it('removes from storage when value is set to null', async () => {
|
||||||
|
const storage = createMockStorage();
|
||||||
|
const state = useStorage<string | null>('test-null', 'value', storage);
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
expect(storage.getItem('test-null')).toBe('value');
|
||||||
|
|
||||||
|
state.value = null;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(storage.getItem('test-null')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Custom serializer ---
|
||||||
|
|
||||||
|
it('uses custom serializer', async () => {
|
||||||
|
const storage = createMockStorage();
|
||||||
|
|
||||||
|
const serializer = {
|
||||||
|
read: (v: string) => v.split(',').map(Number),
|
||||||
|
write: (v: number[]) => v.join(','),
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = useStorage('custom-ser', [1, 2, 3], storage, { serializer });
|
||||||
|
|
||||||
|
expect(state.value).toEqual([1, 2, 3]);
|
||||||
|
|
||||||
|
state.value = [4, 5, 6];
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(storage.getItem('custom-ser')).toBe('4,5,6');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Merge defaults ---
|
||||||
|
|
||||||
|
it('merges defaults with stored value', () => {
|
||||||
|
const storage = createMockStorage();
|
||||||
|
storage.store.set('merge-test', JSON.stringify({ hello: 'stored' }));
|
||||||
|
|
||||||
|
const state = useStorage(
|
||||||
|
'merge-test',
|
||||||
|
{ hello: 'default', greeting: 'hi' },
|
||||||
|
storage,
|
||||||
|
{ mergeDefaults: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.value.hello).toBe('stored');
|
||||||
|
expect(state.value.greeting).toBe('hi');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses custom merge function', () => {
|
||||||
|
const storage = createMockStorage();
|
||||||
|
storage.store.set('merge-fn', JSON.stringify({ a: 1 }));
|
||||||
|
|
||||||
|
const state = useStorage(
|
||||||
|
'merge-fn',
|
||||||
|
{ a: 0, b: 2 },
|
||||||
|
storage,
|
||||||
|
{
|
||||||
|
mergeDefaults: (stored, defaults) => ({ ...defaults, ...stored, b: stored.b ?? defaults.b }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.value).toEqual({ a: 1, b: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Map and Set ---
|
||||||
|
|
||||||
|
it('stores and reads a Map', async () => {
|
||||||
|
const storage = createMockStorage();
|
||||||
|
const initial = new Map([['key1', 'val1']]);
|
||||||
|
const state = useStorage('test-map', initial, storage);
|
||||||
|
|
||||||
|
expect(state.value).toEqual(new Map([['key1', 'val1']]));
|
||||||
|
|
||||||
|
state.value = new Map([['key2', 'val2']]);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const raw = storage.getItem('test-map');
|
||||||
|
expect(JSON.parse(raw!)).toEqual([['key2', 'val2']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores and reads a Set', async () => {
|
||||||
|
const storage = createMockStorage();
|
||||||
|
const initial = new Set([1, 2, 3]);
|
||||||
|
const state = useStorage('test-set', initial, storage);
|
||||||
|
|
||||||
|
expect(state.value).toEqual(new Set([1, 2, 3]));
|
||||||
|
|
||||||
|
state.value = new Set([4, 5]);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const raw = storage.getItem('test-set');
|
||||||
|
expect(JSON.parse(raw!)).toEqual([4, 5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Date ---
|
||||||
|
|
||||||
|
it('stores and reads a Date', async () => {
|
||||||
|
const storage = createMockStorage();
|
||||||
|
const date = new Date('2026-02-14T00:00:00.000Z');
|
||||||
|
const state = useStorage('test-date', date, storage);
|
||||||
|
|
||||||
|
expect(state.value).toEqual(date);
|
||||||
|
|
||||||
|
const newDate = new Date('2026-12-25T00:00:00.000Z');
|
||||||
|
state.value = newDate;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(storage.getItem('test-date')).toBe(newDate.toISOString());
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Error handling ---
|
||||||
|
|
||||||
|
it('calls onError when read fails', () => {
|
||||||
|
const storage = createMockStorage();
|
||||||
|
storage.store.set('bad-json', '{invalid');
|
||||||
|
const onError = vi.fn();
|
||||||
|
|
||||||
|
const state = useStorage('bad-json', { fallback: true }, storage, { onError });
|
||||||
|
|
||||||
|
expect(onError).toHaveBeenCalledOnce();
|
||||||
|
expect(state.value).toEqual({ fallback: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onError when write fails', async () => {
|
||||||
|
const onError = vi.fn();
|
||||||
|
const storage: StorageLike = {
|
||||||
|
getItem: () => null,
|
||||||
|
setItem: () => { throw new Error('quota exceeded'); },
|
||||||
|
removeItem: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = useStorage<string>('fail-write', 'init', storage, { onError });
|
||||||
|
|
||||||
|
// One error from initial persist of defaults
|
||||||
|
expect(onError).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
state.value = 'new';
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
// Another error from the write triggered by value change
|
||||||
|
expect(onError).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Persists defaults on init ---
|
||||||
|
|
||||||
|
it('persists default value to storage on init when key does not exist', () => {
|
||||||
|
const storage = createMockStorage();
|
||||||
|
useStorage('new-key', 'default-val', storage);
|
||||||
|
|
||||||
|
expect(storage.getItem('new-key')).toBe('default-val');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not overwrite existing storage value with defaults', () => {
|
||||||
|
const storage = createMockStorage();
|
||||||
|
storage.store.set('existing-key', 'existing-val');
|
||||||
|
|
||||||
|
useStorage('existing-key', 'default-val', storage);
|
||||||
|
|
||||||
|
expect(storage.getItem('existing-key')).toBe('existing-val');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- writeDefaults: false ---
|
||||||
|
|
||||||
|
it('does not persist defaults when writeDefaults is false', () => {
|
||||||
|
const storage = createMockStorage();
|
||||||
|
useStorage('no-write', 'default-val', storage, { writeDefaults: false });
|
||||||
|
|
||||||
|
expect(storage.getItem('no-write')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- No infinite loop on init ---
|
||||||
|
|
||||||
|
it('calls setItem exactly once on init for writeDefaults', () => {
|
||||||
|
const setItem = vi.fn();
|
||||||
|
const storage: StorageLike = {
|
||||||
|
getItem: () => null,
|
||||||
|
setItem,
|
||||||
|
removeItem: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
useStorage<string>('init-key', 'value', storage);
|
||||||
|
|
||||||
|
expect(setItem).toHaveBeenCalledOnce();
|
||||||
|
expect(setItem).toHaveBeenCalledWith('init-key', 'value');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- No-op write when value unchanged ---
|
||||||
|
|
||||||
|
it('does not call setItem when value is unchanged', async () => {
|
||||||
|
const storage = createMockStorage();
|
||||||
|
const state = useStorage<string>('noop-key', 'same', storage);
|
||||||
|
|
||||||
|
const setItem = vi.spyOn(storage, 'setItem');
|
||||||
|
setItem.mockClear();
|
||||||
|
|
||||||
|
// Re-assign the same value
|
||||||
|
state.value = 'same';
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(setItem).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- shallow: false with deep mutation ---
|
||||||
|
|
||||||
|
it('writes to storage on deep object mutation with shallow: false', async () => {
|
||||||
|
const storage = createMockStorage();
|
||||||
|
const state = useStorage('deep-obj', { nested: { count: 0 } }, storage, { shallow: false });
|
||||||
|
|
||||||
|
expect(state.value.nested.count).toBe(0);
|
||||||
|
|
||||||
|
state.value.nested.count = 42;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(JSON.parse(storage.getItem('deep-obj')!)).toEqual({ nested: { count: 42 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Multiple rapid assignments ---
|
||||||
|
|
||||||
|
it('only writes last value when multiple assignments happen before flush', async () => {
|
||||||
|
const storage = createMockStorage();
|
||||||
|
const state = useStorage<string>('rapid-key', 'initial', storage);
|
||||||
|
|
||||||
|
const setItem = vi.spyOn(storage, 'setItem');
|
||||||
|
setItem.mockClear();
|
||||||
|
|
||||||
|
state.value = 'first';
|
||||||
|
state.value = 'second';
|
||||||
|
state.value = 'third';
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
// Watcher fires once with the last value (pre flush batches)
|
||||||
|
expect(storage.getItem('rapid-key')).toBe('third');
|
||||||
|
});
|
||||||
|
});
|
||||||
223
web/vue/src/composables/storage/useStorage/index.ts
Normal file
223
web/vue/src/composables/storage/useStorage/index.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import { ref, shallowRef, watch, toValue, type Ref, type MaybeRefOrGetter } from 'vue';
|
||||||
|
import { isBoolean, isNumber, isString, isObject, isMap, isSet, isDate } from '@robonen/stdlib';
|
||||||
|
import type { ConfigurableFlush } from '@/types';
|
||||||
|
|
||||||
|
export interface StorageSerializer<T> {
|
||||||
|
read: (raw: string) => T;
|
||||||
|
write: (value: T) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StorageSerializers: { [K: string]: StorageSerializer<any> } & {
|
||||||
|
boolean: StorageSerializer<boolean>;
|
||||||
|
number: StorageSerializer<number>;
|
||||||
|
string: StorageSerializer<string>;
|
||||||
|
object: StorageSerializer<any>;
|
||||||
|
map: StorageSerializer<Map<any, any>>;
|
||||||
|
set: StorageSerializer<Set<any>>;
|
||||||
|
date: StorageSerializer<Date>;
|
||||||
|
} = {
|
||||||
|
boolean: {
|
||||||
|
read: (v: string) => v === 'true',
|
||||||
|
write: (v: boolean) => String(v),
|
||||||
|
},
|
||||||
|
number: {
|
||||||
|
read: (v: string) => Number.parseFloat(v),
|
||||||
|
write: (v: number) => String(v),
|
||||||
|
},
|
||||||
|
string: {
|
||||||
|
read: (v: string) => v,
|
||||||
|
write: (v: string) => v,
|
||||||
|
},
|
||||||
|
object: {
|
||||||
|
read: (v: string) => JSON.parse(v),
|
||||||
|
write: (v: any) => JSON.stringify(v),
|
||||||
|
},
|
||||||
|
map: {
|
||||||
|
read: (v: string) => new Map(JSON.parse(v)),
|
||||||
|
write: (v: Map<any, any>) => JSON.stringify([...v.entries()]),
|
||||||
|
},
|
||||||
|
set: {
|
||||||
|
read: (v: string) => new Set(JSON.parse(v)),
|
||||||
|
write: (v: Set<any>) => JSON.stringify([...v]),
|
||||||
|
},
|
||||||
|
date: {
|
||||||
|
read: (v: string) => new Date(v),
|
||||||
|
write: (v: Date) => v.toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface StorageLike {
|
||||||
|
getItem: (key: string) => string | null;
|
||||||
|
setItem: (key: string, value: string) => void;
|
||||||
|
removeItem: (key: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseStorageOptions<T> extends ConfigurableFlush {
|
||||||
|
/**
|
||||||
|
* Use shallowRef instead of ref for the internal state
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
shallow?: boolean;
|
||||||
|
/**
|
||||||
|
* Watch for deep changes
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
deep?: boolean;
|
||||||
|
/**
|
||||||
|
* Write the default value to the storage when it does not exist
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
writeDefaults?: boolean;
|
||||||
|
/**
|
||||||
|
* Custom serializer for reading/writing storage values
|
||||||
|
*/
|
||||||
|
serializer?: StorageSerializer<T>;
|
||||||
|
/**
|
||||||
|
* Merge the default value with the stored value
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
mergeDefaults?: boolean | ((stored: T, defaults: T) => T);
|
||||||
|
/**
|
||||||
|
* Error handler for read/write failures
|
||||||
|
*/
|
||||||
|
onError?: (error: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseStorageReturn<T> = Ref<T>;
|
||||||
|
|
||||||
|
export function guessSerializer<T>(value: T): StorageSerializer<T> {
|
||||||
|
if (isBoolean(value)) return StorageSerializers.boolean as any;
|
||||||
|
if (isNumber(value)) return StorageSerializers.number as any;
|
||||||
|
if (isString(value)) return StorageSerializers.string as any;
|
||||||
|
if (isMap(value)) return StorageSerializers.map as any;
|
||||||
|
if (isSet(value)) return StorageSerializers.set as any;
|
||||||
|
if (isDate(value)) return StorageSerializers.date as any;
|
||||||
|
if (isObject(value)) return StorageSerializers.object as any;
|
||||||
|
|
||||||
|
return StorageSerializers.object as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shallowMerge<T>(stored: T, defaults: T): T {
|
||||||
|
if (isObject(stored) && isObject(defaults))
|
||||||
|
return { ...defaults, ...stored };
|
||||||
|
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useStorage
|
||||||
|
* @category Storage
|
||||||
|
* @description Reactive Storage binding — creates a ref synced with a storage backend
|
||||||
|
*
|
||||||
|
* @param {string} key The storage key
|
||||||
|
* @param {MaybeRefOrGetter<T>} initialValue The initial/default value
|
||||||
|
* @param {StorageLike} storage The storage backend
|
||||||
|
* @param {UseStorageOptions<T>} [options={}] Options
|
||||||
|
* @returns {Ref<T>} A reactive ref synced with storage
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const count = useStorage('my-count', 0, storage);
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const state = useStorage('my-state', { hello: 'world' }, storage);
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const id = useStorage('my-id', 'default', storage, {
|
||||||
|
* serializer: { read: (v) => v, write: (v) => v },
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* @since 0.0.12
|
||||||
|
*/
|
||||||
|
export function useStorage<T extends string>(key: string, initialValue: MaybeRefOrGetter<T>, storage: StorageLike, options?: UseStorageOptions<T>): Ref<T>;
|
||||||
|
export function useStorage<T extends number>(key: string, initialValue: MaybeRefOrGetter<T>, storage: StorageLike, options?: UseStorageOptions<T>): Ref<T>;
|
||||||
|
export function useStorage<T extends boolean>(key: string, initialValue: MaybeRefOrGetter<T>, storage: StorageLike, options?: UseStorageOptions<T>): Ref<T>;
|
||||||
|
export function useStorage<T>(key: string, initialValue: MaybeRefOrGetter<T>, storage: StorageLike, options?: UseStorageOptions<T>): Ref<T>;
|
||||||
|
export function useStorage<T = unknown>(key: string, initialValue: MaybeRefOrGetter<null>, storage: StorageLike, options?: UseStorageOptions<T>): Ref<T>;
|
||||||
|
export function useStorage<T>(
|
||||||
|
key: string,
|
||||||
|
initialValue: MaybeRefOrGetter<T>,
|
||||||
|
storage: StorageLike,
|
||||||
|
options: UseStorageOptions<T> = {},
|
||||||
|
): Ref<T> {
|
||||||
|
const {
|
||||||
|
shallow = true,
|
||||||
|
deep = true,
|
||||||
|
flush = 'pre',
|
||||||
|
writeDefaults = true,
|
||||||
|
mergeDefaults = false,
|
||||||
|
onError = console.error,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const defaults = toValue(initialValue);
|
||||||
|
const serializer = options.serializer ?? guessSerializer(defaults);
|
||||||
|
|
||||||
|
const data = (shallow ? shallowRef : ref)(defaults) as Ref<T>;
|
||||||
|
|
||||||
|
function read(): T {
|
||||||
|
const raw = storage.getItem(key);
|
||||||
|
|
||||||
|
if (raw == null) {
|
||||||
|
if (writeDefaults && defaults != null) {
|
||||||
|
try {
|
||||||
|
storage.setItem(key, serializer.write(defaults));
|
||||||
|
} catch (e) {
|
||||||
|
onError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let value = serializer.read(raw);
|
||||||
|
|
||||||
|
if (mergeDefaults) {
|
||||||
|
value = typeof mergeDefaults === 'function'
|
||||||
|
? mergeDefaults(value, defaults)
|
||||||
|
: shallowMerge(value, defaults);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
} catch (e) {
|
||||||
|
onError(e);
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(value: T) {
|
||||||
|
try {
|
||||||
|
const oldValue = storage.getItem(key);
|
||||||
|
|
||||||
|
if (value == null) {
|
||||||
|
storage.removeItem(key);
|
||||||
|
} else {
|
||||||
|
const serialized = serializer.write(value);
|
||||||
|
|
||||||
|
if (oldValue !== serialized)
|
||||||
|
storage.setItem(key, serialized);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
onError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function update() {
|
||||||
|
pauseWatch();
|
||||||
|
|
||||||
|
try {
|
||||||
|
data.value = read();
|
||||||
|
} catch (e) {
|
||||||
|
onError(e);
|
||||||
|
} finally {
|
||||||
|
resumeWatch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pause: pauseWatch, resume: resumeWatch } = watch(data, (newValue) => {
|
||||||
|
write(newValue);
|
||||||
|
}, { flush, deep });
|
||||||
|
|
||||||
|
update();
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
335
web/vue/src/composables/storage/useStorageAsync/index.test.ts
Normal file
335
web/vue/src/composables/storage/useStorageAsync/index.test.ts
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { nextTick } from 'vue';
|
||||||
|
import { useStorageAsync, type StorageLikeAsync } from '.';
|
||||||
|
|
||||||
|
function createMockAsyncStorage(): StorageLikeAsync & { store: Map<string, string> } {
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
getItem: async (key: string) => store.get(key) ?? null,
|
||||||
|
setItem: async (key: string, value: string) => { store.set(key, value); },
|
||||||
|
removeItem: async (key: string) => { store.delete(key); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDelayedAsyncStorage(delay: number): StorageLikeAsync & { store: Map<string, string> } {
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
getItem: (key: string) => new Promise((resolve) => setTimeout(() => resolve(store.get(key) ?? null), delay)),
|
||||||
|
setItem: (key: string, value: string) => new Promise((resolve) => setTimeout(() => { store.set(key, value); resolve(); }, delay)),
|
||||||
|
removeItem: (key: string) => new Promise((resolve) => setTimeout(() => { store.delete(key); resolve(); }, delay)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useStorageAsync', () => {
|
||||||
|
// --- Basic read/write ---
|
||||||
|
|
||||||
|
it('returns default value before storage is ready', () => {
|
||||||
|
const storage = createMockAsyncStorage();
|
||||||
|
const { state, isReady } = useStorageAsync('key', 'default', storage);
|
||||||
|
|
||||||
|
expect(state.value).toBe('default');
|
||||||
|
expect(isReady.value).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads existing value from async storage', async () => {
|
||||||
|
const storage = createMockAsyncStorage();
|
||||||
|
storage.store.set('key', 'stored');
|
||||||
|
|
||||||
|
const { state, isReady } = await useStorageAsync('key', 'default', storage);
|
||||||
|
|
||||||
|
expect(state.value).toBe('stored');
|
||||||
|
expect(isReady.value).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes value to async storage on change', async () => {
|
||||||
|
const storage = createMockAsyncStorage();
|
||||||
|
const { state } = await useStorageAsync<string>('key', 'initial', storage);
|
||||||
|
|
||||||
|
state.value = 'updated';
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
// Allow async write to complete
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(storage.store.get('key')).toBe('updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
|
||||||
|
it('reads and writes a number', async () => {
|
||||||
|
const storage = createMockAsyncStorage();
|
||||||
|
storage.store.set('num', '42');
|
||||||
|
|
||||||
|
const { state } = await useStorageAsync<number>('num', 0, storage);
|
||||||
|
|
||||||
|
expect(state.value).toBe(42);
|
||||||
|
|
||||||
|
state.value = 100;
|
||||||
|
await nextTick();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(storage.store.get('num')).toBe('100');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads and writes a boolean', async () => {
|
||||||
|
const storage = createMockAsyncStorage();
|
||||||
|
storage.store.set('flag', 'true');
|
||||||
|
|
||||||
|
const { state } = await useStorageAsync('flag', false, storage);
|
||||||
|
|
||||||
|
expect(state.value).toBe(true);
|
||||||
|
|
||||||
|
state.value = false;
|
||||||
|
await nextTick();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(storage.store.get('flag')).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads and writes an object', async () => {
|
||||||
|
const storage = createMockAsyncStorage();
|
||||||
|
storage.store.set('obj', JSON.stringify({ a: 1 }));
|
||||||
|
|
||||||
|
const { state } = await useStorageAsync('obj', { a: 0, b: 2 }, storage);
|
||||||
|
|
||||||
|
expect(state.value).toEqual({ a: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Awaitable ---
|
||||||
|
|
||||||
|
it('is awaitable and resolves after initial read', async () => {
|
||||||
|
const storage = createDelayedAsyncStorage(50);
|
||||||
|
storage.store.set('delayed', 'loaded');
|
||||||
|
|
||||||
|
const { state, isReady } = await useStorageAsync('delayed', 'default', storage);
|
||||||
|
|
||||||
|
expect(state.value).toBe('loaded');
|
||||||
|
expect(isReady.value).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- onReady callback ---
|
||||||
|
|
||||||
|
it('calls onReady callback after initial load', async () => {
|
||||||
|
const storage = createMockAsyncStorage();
|
||||||
|
storage.store.set('ready', 'ready-value');
|
||||||
|
|
||||||
|
const onReady = vi.fn();
|
||||||
|
|
||||||
|
await useStorageAsync('ready', 'default', storage, { onReady });
|
||||||
|
|
||||||
|
expect(onReady).toHaveBeenCalledOnce();
|
||||||
|
expect(onReady).toHaveBeenCalledWith('ready-value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onReady with default when key not in storage', async () => {
|
||||||
|
const storage = createMockAsyncStorage();
|
||||||
|
const onReady = vi.fn();
|
||||||
|
|
||||||
|
await useStorageAsync('missing', 'fallback', storage, { onReady });
|
||||||
|
|
||||||
|
expect(onReady).toHaveBeenCalledWith('fallback');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Merge defaults ---
|
||||||
|
|
||||||
|
it('merges defaults with stored value', async () => {
|
||||||
|
const storage = createMockAsyncStorage();
|
||||||
|
storage.store.set('merge', JSON.stringify({ hello: 'stored' }));
|
||||||
|
|
||||||
|
const { state } = await useStorageAsync(
|
||||||
|
'merge',
|
||||||
|
{ hello: 'default', greeting: 'hi' },
|
||||||
|
storage,
|
||||||
|
{ mergeDefaults: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.value.hello).toBe('stored');
|
||||||
|
expect(state.value.greeting).toBe('hi');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses custom merge function', async () => {
|
||||||
|
const storage = createMockAsyncStorage();
|
||||||
|
storage.store.set('merge-fn', JSON.stringify({ a: 1 }));
|
||||||
|
|
||||||
|
const { state } = await useStorageAsync(
|
||||||
|
'merge-fn',
|
||||||
|
{ a: 0, b: 2 },
|
||||||
|
storage,
|
||||||
|
{
|
||||||
|
mergeDefaults: (stored, defaults) => ({ ...defaults, ...stored, b: stored.b ?? defaults.b }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.value).toEqual({ a: 1, b: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Custom serializer ---
|
||||||
|
|
||||||
|
it('uses custom async serializer', async () => {
|
||||||
|
const storage = createMockAsyncStorage();
|
||||||
|
storage.store.set('custom', '1,2,3');
|
||||||
|
|
||||||
|
const serializer = {
|
||||||
|
read: async (v: string) => v.split(',').map(Number),
|
||||||
|
write: async (v: number[]) => v.join(','),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { state } = await useStorageAsync('custom', [0], storage, { serializer });
|
||||||
|
|
||||||
|
expect(state.value).toEqual([1, 2, 3]);
|
||||||
|
|
||||||
|
state.value = [4, 5, 6];
|
||||||
|
await nextTick();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(storage.store.get('custom')).toBe('4,5,6');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Null / remove ---
|
||||||
|
|
||||||
|
it('removes from storage when value is set to null', async () => {
|
||||||
|
const storage = createMockAsyncStorage();
|
||||||
|
storage.store.set('nullable', 'exists');
|
||||||
|
|
||||||
|
const { state } = await useStorageAsync<string | null>('nullable', 'default', storage);
|
||||||
|
|
||||||
|
state.value = null;
|
||||||
|
await nextTick();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(storage.store.has('nullable')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Error handling ---
|
||||||
|
|
||||||
|
it('calls onError when read fails', async () => {
|
||||||
|
const onError = vi.fn();
|
||||||
|
const storage: StorageLikeAsync = {
|
||||||
|
getItem: async () => { throw new Error('read failure'); },
|
||||||
|
setItem: async () => {},
|
||||||
|
removeItem: async () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { state } = await useStorageAsync('fail-read', 'fallback', storage, { onError });
|
||||||
|
|
||||||
|
expect(onError).toHaveBeenCalledOnce();
|
||||||
|
expect(state.value).toBe('fallback');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onError when write fails', async () => {
|
||||||
|
const onError = vi.fn();
|
||||||
|
const storage: StorageLikeAsync = {
|
||||||
|
getItem: async () => null,
|
||||||
|
setItem: async () => { throw new Error('write failure'); },
|
||||||
|
removeItem: async () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { state } = await useStorageAsync<string>('fail-write', 'initial', storage, { onError });
|
||||||
|
|
||||||
|
// One error from writeDefaults persisting initial value
|
||||||
|
expect(onError).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
state.value = 'new';
|
||||||
|
await nextTick();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
// Another error from the write triggered by value change
|
||||||
|
expect(onError).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- No unnecessary write-back on initial read ---
|
||||||
|
|
||||||
|
it('does not write back to storage after initial read', async () => {
|
||||||
|
const setItem = vi.fn(async () => {});
|
||||||
|
const storage: StorageLikeAsync = {
|
||||||
|
getItem: async () => 'existing',
|
||||||
|
setItem,
|
||||||
|
removeItem: async () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
await useStorageAsync('key', 'default', storage);
|
||||||
|
await nextTick();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(setItem).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not write back to storage when key is missing and writeDefaults is false', async () => {
|
||||||
|
const setItem = vi.fn(async () => {});
|
||||||
|
const storage: StorageLikeAsync = {
|
||||||
|
getItem: async () => null,
|
||||||
|
setItem,
|
||||||
|
removeItem: async () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
await useStorageAsync('key', 'default', storage, { writeDefaults: false });
|
||||||
|
await nextTick();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(setItem).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- shallow: false with deep mutation ---
|
||||||
|
|
||||||
|
it('writes to storage on deep object mutation with shallow: false', async () => {
|
||||||
|
const storage = createMockAsyncStorage();
|
||||||
|
const { state } = await useStorageAsync(
|
||||||
|
'deep-obj',
|
||||||
|
{ nested: { count: 0 } },
|
||||||
|
storage,
|
||||||
|
{ shallow: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
state.value.nested.count = 42;
|
||||||
|
await nextTick();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(JSON.parse(storage.store.get('deep-obj')!)).toEqual({ nested: { count: 42 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Multiple rapid assignments ---
|
||||||
|
|
||||||
|
it('handles multiple rapid assignments', async () => {
|
||||||
|
const storage = createMockAsyncStorage();
|
||||||
|
const { state } = await useStorageAsync<string>('rapid', 'initial', storage);
|
||||||
|
|
||||||
|
state.value = 'first';
|
||||||
|
state.value = 'second';
|
||||||
|
state.value = 'third';
|
||||||
|
await nextTick();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(storage.store.get('rapid')).toBe('third');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- writeDefaults ---
|
||||||
|
|
||||||
|
it('persists defaults to storage when key does not exist', async () => {
|
||||||
|
const storage = createMockAsyncStorage();
|
||||||
|
|
||||||
|
await useStorageAsync('new-key', 'default-val', storage);
|
||||||
|
|
||||||
|
expect(storage.store.get('new-key')).toBe('default-val');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not persist defaults when writeDefaults is false', async () => {
|
||||||
|
const storage = createMockAsyncStorage();
|
||||||
|
|
||||||
|
await useStorageAsync('new-key', 'default-val', storage, { writeDefaults: false });
|
||||||
|
|
||||||
|
expect(storage.store.has('new-key')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not overwrite existing value with defaults', async () => {
|
||||||
|
const storage = createMockAsyncStorage();
|
||||||
|
storage.store.set('existing', 'stored');
|
||||||
|
|
||||||
|
await useStorageAsync('existing', 'default', storage);
|
||||||
|
|
||||||
|
expect(storage.store.get('existing')).toBe('stored');
|
||||||
|
});
|
||||||
|
});
|
||||||
186
web/vue/src/composables/storage/useStorageAsync/index.ts
Normal file
186
web/vue/src/composables/storage/useStorageAsync/index.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { ref, shallowRef, watch, toValue, type Ref, type ShallowRef, type MaybeRefOrGetter, type UnwrapRef } from 'vue';
|
||||||
|
import type { ConfigurableFlush } from '@/types';
|
||||||
|
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||||
|
import { guessSerializer, shallowMerge } from '../useStorage';
|
||||||
|
|
||||||
|
export interface StorageSerializerAsync<T> {
|
||||||
|
read: (raw: string) => T | Promise<T>;
|
||||||
|
write: (value: T) => string | Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StorageLikeAsync {
|
||||||
|
getItem: (key: string) => string | null | Promise<string | null>;
|
||||||
|
setItem: (key: string, value: string) => void | Promise<void>;
|
||||||
|
removeItem: (key: string) => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseStorageAsyncOptions<T, Shallow extends boolean = true> extends ConfigurableFlush {
|
||||||
|
/**
|
||||||
|
* Use shallowRef instead of ref for the internal state
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
shallow?: Shallow;
|
||||||
|
/**
|
||||||
|
* Watch for deep changes
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
deep?: boolean;
|
||||||
|
/**
|
||||||
|
* Write the default value to the storage when it does not exist
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
writeDefaults?: boolean;
|
||||||
|
/**
|
||||||
|
* Custom serializer for reading/writing storage values
|
||||||
|
*/
|
||||||
|
serializer?: StorageSerializerAsync<T>;
|
||||||
|
/**
|
||||||
|
* Merge the default value with the stored value
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
mergeDefaults?: boolean | ((stored: T, defaults: T) => T);
|
||||||
|
/**
|
||||||
|
* Called once when the initial value has been loaded from storage
|
||||||
|
*/
|
||||||
|
onReady?: (value: T) => void;
|
||||||
|
/**
|
||||||
|
* Error handler for read/write failures
|
||||||
|
*/
|
||||||
|
onError?: (error: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseStorageAsyncReturnBase<T, Shallow extends boolean> {
|
||||||
|
state: Shallow extends true ? ShallowRef<T> : Ref<UnwrapRef<T>>;
|
||||||
|
isReady: Ref<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseStorageAsyncReturn<T, Shallow extends boolean> =
|
||||||
|
& UseStorageAsyncReturnBase<T, Shallow>
|
||||||
|
& PromiseLike<UseStorageAsyncReturnBase<T, Shallow>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useStorageAsync
|
||||||
|
* @category Storage
|
||||||
|
* @description Reactive Storage binding with async support — creates a ref synced with an async storage backend
|
||||||
|
*
|
||||||
|
* @param {string} key The storage key
|
||||||
|
* @param {MaybeRefOrGetter<T>} initialValue The initial/default value
|
||||||
|
* @param {StorageLikeAsync} storage The async storage backend
|
||||||
|
* @param {UseStorageAsyncOptions<T>} [options={}] Options
|
||||||
|
* @returns {UseStorageAsyncReturn<T, Shallow>} An object with state ref and isReady flag, also awaitable
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { state } = useStorageAsync('access-token', '', asyncStorage);
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { state, isReady } = await useStorageAsync('settings', { theme: 'dark' }, asyncStorage);
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { state } = useStorageAsync('key', 'default', asyncStorage, {
|
||||||
|
* onReady: (value) => console.log('Loaded:', value),
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* @since 0.0.12
|
||||||
|
*/
|
||||||
|
export function useStorageAsync<T extends string, Shallow extends boolean = true>(key: string, initialValue: MaybeRefOrGetter<T>, storage: StorageLikeAsync, options?: UseStorageAsyncOptions<T, Shallow>): UseStorageAsyncReturn<T, Shallow>;
|
||||||
|
export function useStorageAsync<T extends number, Shallow extends boolean = true>(key: string, initialValue: MaybeRefOrGetter<T>, storage: StorageLikeAsync, options?: UseStorageAsyncOptions<T, Shallow>): UseStorageAsyncReturn<T, Shallow>;
|
||||||
|
export function useStorageAsync<T extends boolean, Shallow extends boolean = true>(key: string, initialValue: MaybeRefOrGetter<T>, storage: StorageLikeAsync, options?: UseStorageAsyncOptions<T, Shallow>): UseStorageAsyncReturn<T, Shallow>;
|
||||||
|
export function useStorageAsync<T, Shallow extends boolean = true>(key: string, initialValue: MaybeRefOrGetter<T>, storage: StorageLikeAsync, options?: UseStorageAsyncOptions<T, Shallow>): UseStorageAsyncReturn<T, Shallow>;
|
||||||
|
export function useStorageAsync<T = unknown, Shallow extends boolean = true>(key: string, initialValue: MaybeRefOrGetter<null>, storage: StorageLikeAsync, options?: UseStorageAsyncOptions<T, Shallow>): UseStorageAsyncReturn<T, Shallow>;
|
||||||
|
export function useStorageAsync<T, Shallow extends boolean = true>(
|
||||||
|
key: string,
|
||||||
|
initialValue: MaybeRefOrGetter<T>,
|
||||||
|
storage: StorageLikeAsync,
|
||||||
|
options: UseStorageAsyncOptions<T, Shallow> = {},
|
||||||
|
): UseStorageAsyncReturn<T, Shallow> {
|
||||||
|
const {
|
||||||
|
shallow = true,
|
||||||
|
deep = true,
|
||||||
|
flush = 'pre',
|
||||||
|
writeDefaults = true,
|
||||||
|
mergeDefaults = false,
|
||||||
|
onReady,
|
||||||
|
onError = console.error,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const defaults = toValue(initialValue);
|
||||||
|
const serializer = options.serializer ?? guessSerializer(defaults);
|
||||||
|
|
||||||
|
const state = (shallow ? shallowRef : ref)(defaults) as Shallow extends true ? ShallowRef<T> : Ref<UnwrapRef<T>>;
|
||||||
|
const isReady = ref(false);
|
||||||
|
|
||||||
|
async function read(): Promise<T> {
|
||||||
|
try {
|
||||||
|
const raw = await storage.getItem(key);
|
||||||
|
|
||||||
|
if (raw == null) {
|
||||||
|
if (writeDefaults && defaults != null) {
|
||||||
|
try {
|
||||||
|
await storage.setItem(key, await serializer.write(defaults));
|
||||||
|
} catch (e) {
|
||||||
|
onError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
let value: T = await serializer.read(raw) as T;
|
||||||
|
|
||||||
|
if (mergeDefaults) {
|
||||||
|
value = typeof mergeDefaults === 'function'
|
||||||
|
? mergeDefaults(value, defaults)
|
||||||
|
: shallowMerge(value, defaults);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
} catch (e) {
|
||||||
|
onError(e);
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function write(value: T) {
|
||||||
|
try {
|
||||||
|
if (value == null) {
|
||||||
|
await storage.removeItem(key);
|
||||||
|
} else {
|
||||||
|
const raw = await serializer.write(value);
|
||||||
|
await storage.setItem(key, raw);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
onError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let stopWatch: (() => void) | null = null;
|
||||||
|
|
||||||
|
tryOnScopeDispose(() => stopWatch?.());
|
||||||
|
|
||||||
|
const shell: UseStorageAsyncReturnBase<T, Shallow> = {
|
||||||
|
state,
|
||||||
|
isReady,
|
||||||
|
};
|
||||||
|
|
||||||
|
const readyPromise: Promise<UseStorageAsyncReturnBase<T, Shallow>> = read().then((value) => {
|
||||||
|
(state as Ref).value = value;
|
||||||
|
isReady.value = true;
|
||||||
|
onReady?.(value);
|
||||||
|
|
||||||
|
// Set up watcher AFTER initial state is set — avoids write-back on init
|
||||||
|
const stop = watch(state, (newValue) => {
|
||||||
|
write(newValue as T);
|
||||||
|
}, { flush, deep });
|
||||||
|
|
||||||
|
stopWatch = stop;
|
||||||
|
|
||||||
|
return shell;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...shell,
|
||||||
|
then(onFulfilled, onRejected) {
|
||||||
|
return readyPromise.then(onFulfilled, onRejected);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
1
web/vue/src/composables/utilities/index.ts
Normal file
1
web/vue/src/composables/utilities/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './useOffsetPagination';
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { VoidFunction } from '@robonen/stdlib';
|
import type { VoidFunction } from '@robonen/stdlib';
|
||||||
import { computed, reactive, toValue, watch, type ComputedRef, type MaybeRef, type MaybeRefOrGetter, type UnwrapNestedRefs, type WritableComputedRef } from 'vue';
|
import { computed, reactive, toValue, watch, type ComputedRef, type MaybeRef, type MaybeRefOrGetter, type UnwrapNestedRefs, type WritableComputedRef } from 'vue';
|
||||||
import { useClamp } from '../useClamp';
|
import { useClamp } from '@/composables/math/useClamp';
|
||||||
|
|
||||||
// TODO: sync returned refs with passed refs
|
// TODO: sync returned refs with passed refs
|
||||||
|
|
||||||
10
web/vue/src/types/flush.ts
Normal file
10
web/vue/src/types/flush.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { WatchOptions } from 'vue';
|
||||||
|
|
||||||
|
export interface ConfigurableFlush {
|
||||||
|
/**
|
||||||
|
* Timing for the watcher flush
|
||||||
|
*
|
||||||
|
* @default 'pre'
|
||||||
|
*/
|
||||||
|
flush?: WatchOptions['flush'];
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
|
export * from './flush';
|
||||||
export * from './resumable';
|
export * from './resumable';
|
||||||
export * from './window';
|
export * from './window';
|
||||||
@@ -1,3 +1,12 @@
|
|||||||
import { isClient } from '@robonen/platform/multi';
|
import { isClient } from '@robonen/platform/multi';
|
||||||
|
|
||||||
export const defaultWindow = /* #__PURE__ */ isClient ? window : undefined
|
export const defaultWindow = /* #__PURE__ */ isClient ? window : undefined
|
||||||
|
|
||||||
|
export interface ConfigurableWindow {
|
||||||
|
/**
|
||||||
|
* Specify a custom `window` instance, e.g. working with iframes or testing environments
|
||||||
|
*
|
||||||
|
* @default defaultWindow
|
||||||
|
*/
|
||||||
|
window?: Window;
|
||||||
|
}
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
{
|
{
|
||||||
"extends": "@robonen/tsconfig/tsconfig.json",
|
"extends": "@robonen/tsconfig/tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["DOM"]
|
"lib": ["DOM"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"],
|
||||||
|
"@/composables/*": ["src/composables/*"],
|
||||||
|
"@/types": ["src/types"],
|
||||||
|
"@/utils": ["src/utils"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
13
web/vue/vitest.config.ts
Normal file
13
web/vue/vitest.config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user