1
0
mirror of https://github.com/robonen/tools.git synced 2026-03-21 03:14:43 +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,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();
},
};
});
}

View File

@@ -0,0 +1,4 @@
export * from './broadcastedRef';
export * from './useCached';
export * from './useLastChanged';
export * from './useSyncRefs';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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