feat(vue): expand @robonen/vue composable collection

Composables, tests, category barrels, and README for @robonen/vue.
This commit is contained in:
2026-06-08 15:51:16 +07:00
parent 9a912f7a77
commit 59e995d0b5
369 changed files with 36554 additions and 188 deletions
@@ -0,0 +1,13 @@
export * from './useArrayDifference';
export * from './useArrayEvery';
export * from './useArrayFilter';
export * from './useArrayFind';
export * from './useArrayFindIndex';
export * from './useArrayFindLast';
export * from './useArrayIncludes';
export * from './useArrayJoin';
export * from './useArrayMap';
export * from './useArrayReduce';
export * from './useArraySome';
export * from './useArrayUnique';
export * from './useSorted';
@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest';
import { ref } from 'vue';
import { useArrayDifference } from '.';
describe(useArrayDifference, () => {
it('returns the asymmetric difference of two arrays', () => {
const list = ref([1, 2, 3, 4, 5]);
const values = ref([2, 4]);
const diff = useArrayDifference(list, values);
expect(diff.value).toEqual([1, 3, 5]);
});
it('returns an empty array when all items are subtracted', () => {
const list = ref([1, 2, 3]);
const values = ref([1, 2, 3, 4]);
const diff = useArrayDifference(list, values);
expect(diff.value).toEqual([]);
});
it('returns the full list when values is empty', () => {
const list = ref([1, 2, 3]);
const values = ref<number[]>([]);
const diff = useArrayDifference(list, values);
expect(diff.value).toEqual([1, 2, 3]);
});
it('reacts to changes in the source array', () => {
const list = ref([1, 2, 3]);
const values = ref([2]);
const diff = useArrayDifference(list, values);
expect(diff.value).toEqual([1, 3]);
list.value = [1, 2, 3, 4];
expect(diff.value).toEqual([1, 3, 4]);
});
it('reacts to changes in the values array', () => {
const list = ref([1, 2, 3]);
const values = ref([2]);
const diff = useArrayDifference(list, values);
expect(diff.value).toEqual([1, 3]);
values.value = [1, 2];
expect(diff.value).toEqual([3]);
});
it('accepts getters as sources', () => {
const a = ref(1);
const b = ref(2);
const diff = useArrayDifference(() => [a.value, b.value, 3], () => [b.value]);
expect(diff.value).toEqual([1, 3]);
b.value = 1;
expect(diff.value).toEqual([3]);
});
it('compares by key (positional argument)', () => {
const list = ref([{ id: 1 }, { id: 2 }, { id: 3 }]);
const values = ref([{ id: 2 }]);
const diff = useArrayDifference(list, values, 'id');
expect(diff.value).toEqual([{ id: 1 }, { id: 3 }]);
});
it('compares with a custom comparator function', () => {
const list = ref([1, 2, 3, 4, 5, 6]);
const values = ref([2]);
// Treat numbers with the same parity as equal.
const diff = useArrayDifference(list, values, (a, b) => a % 2 === b % 2);
expect(diff.value).toEqual([1, 3, 5]);
});
it('returns the symmetric difference via options', () => {
const a = ref([1, 2, 3]);
const b = ref([2, 3, 4]);
const diff = useArrayDifference(a, b, { symmetric: true });
expect(diff.value).toEqual([1, 4]);
});
it('returns the symmetric difference via the trailing options argument', () => {
const a = ref([{ id: 1 }, { id: 2 }]);
const b = ref([{ id: 2 }, { id: 3 }]);
const diff = useArrayDifference(a, b, 'id', { symmetric: true });
expect(diff.value).toEqual([{ id: 1 }, { id: 3 }]);
});
it('accepts a comparator inside the options object', () => {
const list = ref([1, 2, 3, 4]);
const values = ref([20, 30]);
const diff = useArrayDifference(list, values, {
comparator: (a, b) => a === b / 10,
});
expect(diff.value).toEqual([1, 4]);
});
it('reacts to source changes when symmetric', () => {
const a = ref([1, 2]);
const b = ref([2, 3]);
const diff = useArrayDifference(a, b, { symmetric: true });
expect(diff.value).toEqual([1, 3]);
a.value = [1, 2, 3];
expect(diff.value).toEqual([1]);
});
it('does not mutate the source arrays in symmetric mode', () => {
const a = ref([1, 2]);
const b = ref([2, 3]);
const diff = useArrayDifference(a, b, { symmetric: true });
expect(diff.value).toEqual([1, 3]);
expect(a.value).toEqual([1, 2]);
expect(b.value).toEqual([2, 3]);
});
it('is SSR-safe: never touches window/document/navigator', () => {
// Pure computed wrapper — evaluating it relies on no browser globals.
const list = ref([1, 2, 3]);
const values = ref([2]);
const diff = useArrayDifference(list, values);
expect(diff.value).toEqual([1, 3]);
});
});
@@ -0,0 +1,133 @@
import { computed, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
import { isObject, isString } from '@robonen/stdlib';
/**
* Comparator deciding whether two array elements are considered equal.
*/
export type UseArrayDifferenceComparatorFn<T>
= (value: T, othVal: T) => boolean;
export interface UseArrayDifferenceOptions<T> {
/**
* When `true`, returns the symmetric difference: items present in exactly one
* of the two arrays (`list` XOR `values`). When `false`, returns the
* asymmetric difference: items in `list` that are not in `values`.
*
* @see https://en.wikipedia.org/wiki/Symmetric_difference
* @default false
*/
symmetric?: boolean;
/**
* Custom comparator function, or a key of `T` to compare a single property by.
*/
comparator?: UseArrayDifferenceComparatorFn<T> | keyof T;
}
export type UseArrayDifferenceReturn<T = any>
= ComputedRef<T[]>;
function isArrayDifferenceOptions<T>(value: unknown): value is UseArrayDifferenceOptions<T> {
// isObject matches PLAIN objects only, so comparator functions/keys never reach here.
return isObject(value) && ('symmetric' in value || 'comparator' in value);
}
/**
* @name useArrayDifference
* @category Array
* @description Reactive difference of two arrays. Returns items in `list` that are not in `values` (asymmetric), or items in exactly one array (symmetric). Both arrays may be reactive (refs or getters).
*
* @param {MaybeRefOrGetter<T[]>} list The source array
* @param {MaybeRefOrGetter<T[]>} values The array of values to subtract from `list`
* @param {UseArrayDifferenceComparatorFn<T> | keyof T | UseArrayDifferenceOptions<T>} [comparator] A comparator function, a key of `T` to compare by, or an options object with `comparator`/`symmetric`
* @param {UseArrayDifferenceOptions<T>} [options] Extra options when `comparator` is a function or key
* @returns {UseArrayDifferenceReturn<T>} A computed array of the difference
*
* @example
* const list = ref([1, 2, 3, 4, 5]);
* const values = ref([2, 4]);
* const diff = useArrayDifference(list, values); // [1, 3, 5]
*
* @example
* const list = ref([{ id: 1 }, { id: 2 }, { id: 3 }]);
* const values = ref([{ id: 2 }]);
* const diff = useArrayDifference(list, values, 'id'); // [{ id: 1 }, { id: 3 }]
*
* @example
* const a = ref([1, 2, 3]);
* const b = ref([2, 3, 4]);
* const symmetric = useArrayDifference(a, b, { symmetric: true }); // [1, 4]
*
* @since 0.0.15
*/
export function useArrayDifference<T>(
list: MaybeRefOrGetter<T[]>,
values: MaybeRefOrGetter<T[]>,
comparator?: UseArrayDifferenceComparatorFn<T>,
options?: UseArrayDifferenceOptions<T>,
): UseArrayDifferenceReturn<T>;
export function useArrayDifference<T>(
list: MaybeRefOrGetter<T[]>,
values: MaybeRefOrGetter<T[]>,
comparator?: keyof T,
options?: UseArrayDifferenceOptions<T>,
): UseArrayDifferenceReturn<T>;
export function useArrayDifference<T>(
list: MaybeRefOrGetter<T[]>,
values: MaybeRefOrGetter<T[]>,
options?: UseArrayDifferenceOptions<T>,
): UseArrayDifferenceReturn<T>;
export function useArrayDifference<T>(
list: MaybeRefOrGetter<T[]>,
values: MaybeRefOrGetter<T[]>,
comparator?: UseArrayDifferenceComparatorFn<T> | keyof T | UseArrayDifferenceOptions<T>,
options?: UseArrayDifferenceOptions<T>,
): UseArrayDifferenceReturn<T> {
let symmetric = false;
let resolved: UseArrayDifferenceComparatorFn<T> | keyof T | undefined;
if (isArrayDifferenceOptions<T>(comparator)) {
symmetric = comparator.symmetric ?? false;
resolved = comparator.comparator;
}
else {
resolved = comparator;
symmetric = options?.symmetric ?? false;
// An explicit comparator/key in `options` wins over the positional argument.
if (options?.comparator !== undefined)
resolved = options.comparator;
}
// Resolve the comparator once instead of rebuilding it on every recompute.
let compare: UseArrayDifferenceComparatorFn<T>;
if (isString(resolved) || typeof resolved === 'symbol' || typeof resolved === 'number') {
const key = resolved as keyof T;
compare = (value, othVal) => value[key] === othVal[key];
}
else if (typeof resolved === 'function') {
compare = resolved;
}
else {
compare = (value, othVal) => value === othVal;
}
return computed(() => {
const source = toValue(list);
const other = toValue(values);
// Items in `source` absent from `other`.
const diff = source.filter(value => !other.some(othVal => compare(value, othVal)));
if (!symmetric)
return diff;
// Items in `other` absent from `source`, appended for the symmetric difference.
for (const value of other) {
if (!source.some(srcVal => compare(value, srcVal)))
diff.push(value);
}
return diff;
});
}
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import { ref } from 'vue';
import { useArrayEvery } from '.';
describe(useArrayEvery, () => {
it('returns true when every element passes', () => {
const list = ref([1, 2, 3, 4]);
const allPositive = useArrayEvery(list, n => n > 0);
expect(allPositive.value).toBeTruthy();
});
it('returns false when some element fails', () => {
const list = ref([1, -2, 3, 4]);
const allPositive = useArrayEvery(list, n => n > 0);
expect(allPositive.value).toBeFalsy();
});
it('reacts to source array changes', () => {
const list = ref([2, 4, 6]);
const allEven = useArrayEvery(list, n => n % 2 === 0);
expect(allEven.value).toBeTruthy();
list.value = [2, 4, 5];
expect(allEven.value).toBeFalsy();
});
it('unwraps reactive items', () => {
const items = [ref(2), ref(4), ref(6)];
const allEven = useArrayEvery(items, n => n % 2 === 0);
expect(allEven.value).toBeTruthy();
items[1]!.value = 5;
expect(allEven.value).toBeFalsy();
});
it('accepts a getter as the list source', () => {
const a = ref(1);
const b = ref(2);
const allPositive = useArrayEvery(() => [a.value, b.value], n => n > 0);
expect(allPositive.value).toBeTruthy();
b.value = -1;
expect(allPositive.value).toBeFalsy();
});
it('passes index and array to the predicate', () => {
const list = ref([0, 1, 2, 3]);
const matchesIndex = useArrayEvery(list, (element, index, array) => {
expect(array).toBe(list.value);
return element === index;
});
expect(matchesIndex.value).toBeTruthy();
});
it('returns true for an empty array (vacuous truth)', () => {
const list = ref<number[]>([]);
const result = useArrayEvery(list, n => n > 0);
expect(result.value).toBeTruthy();
});
it('is SSR-safe: never touches window/document/navigator', () => {
const list = ref([1, 2, 3]);
// Pure computed wrapper — evaluating it relies on no browser globals.
const result = useArrayEvery(list, n => n > 0);
expect(result.value).toBeTruthy();
});
});
@@ -0,0 +1,30 @@
import { computed, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
export type UseArrayEveryReturn = ComputedRef<boolean>;
/**
* @name useArrayEvery
* @category Array
* @description Reactive `Array.prototype.every`. The source array and its items may be reactive.
*
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
* @param {(element: T, index: number, array: MaybeRefOrGetter<T>[]) => unknown} fn Predicate to test each element
* @returns {UseArrayEveryReturn} A computed boolean that is `true` if `fn` returns a truthy value for every element, otherwise `false`
*
* @example
* const list = ref([1, 2, 3, 4]);
* const allPositive = useArrayEvery(list, n => n > 0); // true
*
* @example
* const items = [ref(2), ref(4), ref(6)];
* const allEven = useArrayEvery(items, n => n % 2 === 0); // true
*
* @since 0.0.15
*/
export function useArrayEvery<T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
fn: (element: T, index: number, array: Array<MaybeRefOrGetter<T>>) => unknown,
): UseArrayEveryReturn {
return computed(() => toValue(list).every((element, index, array) => fn(toValue(element), index, array)));
}
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { ref } from 'vue';
import { useArrayFilter } from '.';
describe(useArrayFilter, () => {
it('filters reactively', () => {
const list = ref([1, 2, 3, 4]);
const even = useArrayFilter(list, n => n % 2 === 0);
expect(even.value).toEqual([2, 4]);
list.value = [1, 3, 5, 6];
expect(even.value).toEqual([6]);
});
it('unwraps reactive items', () => {
const list = [ref(1), ref(2), ref(3)];
const odd = useArrayFilter(list, n => n % 2 === 1);
expect(odd.value).toEqual([1, 3]);
});
});
@@ -0,0 +1,24 @@
import { computed, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
/**
* @name useArrayFilter
* @category Array
* @description Reactive `Array.prototype.filter`.
*
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
* @param {(element: T, index: number, array: T[]) => boolean} fn Predicate
* @returns {ComputedRef<T[]>} The filtered array
*
* @example
* const list = ref([1, 2, 3, 4]);
* const even = useArrayFilter(list, n => n % 2 === 0); // [2, 4]
*
* @since 0.0.15
*/
export function useArrayFilter<T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
fn: (element: T, index: number, array: T[]) => boolean,
): ComputedRef<T[]> {
return computed(() => toValue(list).map(i => toValue(i)).filter(fn));
}
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { ref } from 'vue';
import { useArrayFind } from '.';
describe(useArrayFind, () => {
it('finds reactively', () => {
const list = ref([1, 2, 3]);
const found = useArrayFind(list, n => n > 1);
expect(found.value).toBe(2);
list.value = [10, 20];
expect(found.value).toBe(10);
});
it('returns undefined when nothing matches', () => {
const found = useArrayFind(ref([1, 2]), n => n > 5);
expect(found.value).toBeUndefined();
});
});
@@ -0,0 +1,24 @@
import { computed, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
/**
* @name useArrayFind
* @category Array
* @description Reactive `Array.prototype.find`.
*
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
* @param {(element: T, index: number, array: T[]) => boolean} fn Predicate
* @returns {ComputedRef<T | undefined>} The first matching element
*
* @example
* const list = ref([1, 2, 3]);
* const found = useArrayFind(list, n => n > 1); // 2
*
* @since 0.0.15
*/
export function useArrayFind<T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
fn: (element: T, index: number, array: T[]) => boolean,
): ComputedRef<T | undefined> {
return computed(() => toValue(list).map(i => toValue(i)).find(fn));
}
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import { ref } from 'vue';
import { useArrayFindIndex } from '.';
describe(useArrayFindIndex, () => {
it('finds the index reactively', () => {
const list = ref([1, 2, 3]);
const index = useArrayFindIndex(list, n => n > 1);
expect(index.value).toBe(1);
list.value = [10, 20];
expect(index.value).toBe(0);
});
it('returns -1 when nothing matches', () => {
const index = useArrayFindIndex(ref([1, 2]), n => n > 5);
expect(index.value).toBe(-1);
});
it('returns -1 for an empty array', () => {
const index = useArrayFindIndex(ref<number[]>([]), () => true);
expect(index.value).toBe(-1);
});
it('passes element, index and the resolved array to the predicate', () => {
const calls: Array<[number, number, number[]]> = [];
const index = useArrayFindIndex(ref([5, 6, 7]), (element, idx, array) => {
calls.push([element, idx, array]);
return element === 7;
});
expect(index.value).toBe(2);
expect(calls).toEqual([
[5, 0, [5, 6, 7]],
[6, 1, [5, 6, 7]],
[7, 2, [5, 6, 7]],
]);
});
it('unwraps reactive items inside the list', () => {
const a = ref(1);
const b = ref(2);
const index = useArrayFindIndex([a, b], n => n === 2);
expect(index.value).toBe(1);
b.value = 0;
a.value = 0;
expect(index.value).toBe(-1);
});
it('accepts a getter as the source list', () => {
const source = ref([3, 4, 5]);
const index = useArrayFindIndex(() => source.value, n => n % 2 === 0);
expect(index.value).toBe(1);
source.value = [1, 3, 5];
expect(index.value).toBe(-1);
});
it('accepts a plain (non-reactive) array', () => {
const index = useArrayFindIndex([10, 20, 30], n => n === 30);
expect(index.value).toBe(2);
});
it('returns the FIRST matching index', () => {
const index = useArrayFindIndex(ref([2, 4, 6, 8]), n => n % 2 === 0);
expect(index.value).toBe(0);
});
});
@@ -0,0 +1,29 @@
import { computed, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
export type UseArrayFindIndexReturn = ComputedRef<number>;
/**
* @name useArrayFindIndex
* @category Array
* @description Reactive `Array.prototype.findIndex`.
*
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
* @param {(element: T, index: number, array: T[]) => unknown} fn Predicate testing each element
* @returns {UseArrayFindIndexReturn} The index of the first matching element, or `-1` if none match
*
* @example
* const list = ref([1, 2, 3]);
* const index = useArrayFindIndex(list, n => n > 1); // 1
*
* @since 0.0.15
*/
export function useArrayFindIndex<T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
fn: (element: T, index: number, array: T[]) => unknown,
): UseArrayFindIndexReturn {
return computed(() => {
const resolved = toValue(list).map(item => toValue(item));
return resolved.findIndex(fn);
});
}
@@ -0,0 +1,91 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ref } from 'vue';
import { useArrayFindLast } from '.';
describe(useArrayFindLast, () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('finds the last matching element reactively', () => {
const list = ref([1, 2, 3, 4]);
const found = useArrayFindLast(list, n => n % 2 === 0);
expect(found.value).toBe(4);
list.value = [10, 20, 21];
expect(found.value).toBe(20);
});
it('returns undefined when nothing matches', () => {
const found = useArrayFindLast(ref([1, 2]), n => n > 5);
expect(found.value).toBeUndefined();
});
it('returns undefined for an empty array', () => {
const found = useArrayFindLast(ref<number[]>([]), () => true);
expect(found.value).toBeUndefined();
});
it('returns the LAST matching element', () => {
const found = useArrayFindLast(ref([2, 4, 6, 8]), n => n % 2 === 0);
expect(found.value).toBe(8);
});
it('passes element, index and the resolved array to the predicate', () => {
const calls: Array<[number, number, number[]]> = [];
const found = useArrayFindLast(ref([5, 6, 7]), (element, idx, array) => {
calls.push([element, idx, array]);
return element === 7;
});
expect(found.value).toBe(7);
// findLast iterates from the end; the match at index 2 stops it immediately.
expect(calls).toEqual([
[7, 2, [5, 6, 7]],
]);
});
it('unwraps reactive items inside the list', () => {
const a = ref(1);
const b = ref(2);
const found = useArrayFindLast([a, b], n => n < 5);
expect(found.value).toBe(2);
b.value = 9;
expect(found.value).toBe(1);
});
it('accepts a getter as the source list', () => {
const source = ref([3, 4, 5, 6]);
const found = useArrayFindLast(() => source.value, n => n % 2 === 0);
expect(found.value).toBe(6);
source.value = [1, 3, 5];
expect(found.value).toBeUndefined();
});
it('accepts a plain (non-reactive) array', () => {
const found = useArrayFindLast([10, 20, 30], n => n < 25);
expect(found.value).toBe(20);
});
it('works via the polyfill when Array.prototype.findLast is unavailable', () => {
const native = Array.prototype.findLast;
try {
// Simulate a runtime older than ES2023.
(Array.prototype as { findLast?: unknown }).findLast = undefined;
vi.resetModules();
// The presence check runs at module import time, so re-import here.
return import('.').then(({ useArrayFindLast: useArrayFindLastFresh }) => {
const found = useArrayFindLastFresh(ref([1, 2, 3, 4]), n => n % 2 === 0);
expect(found.value).toBe(4);
const none = useArrayFindLastFresh(ref([1, 3, 5]), n => n % 2 === 0);
expect(none.value).toBeUndefined();
});
}
finally {
(Array.prototype as { findLast?: unknown }).findLast = native;
}
});
});
@@ -0,0 +1,48 @@
import { computed, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
export type UseArrayFindLastReturn<T = unknown>
= ComputedRef<T | undefined>;
/**
* `Array.prototype.findLast` polyfill for runtimes older than ES2023.
*/
function findLast<T>(
array: T[],
fn: (element: T, index: number, array: T[]) => unknown,
): T | undefined {
let index = array.length;
while (index-- > 0) {
const element = array[index]!;
if (fn(element, index, array))
return element;
}
return undefined;
}
const hasNativeFindLast = typeof Array.prototype.findLast === 'function';
/**
* @name useArrayFindLast
* @category Array
* @description Reactive `Array.prototype.findLast`.
*
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
* @param {(element: T, index: number, array: T[]) => unknown} fn Predicate testing each element
* @returns {UseArrayFindLastReturn<T>} The last matching element, or `undefined` if none match
*
* @example
* const list = ref([1, 2, 3, 4]);
* const found = useArrayFindLast(list, n => n % 2 === 0); // 4
*
* @since 0.0.15
*/
export function useArrayFindLast<T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
fn: (element: T, index: number, array: T[]) => unknown,
): UseArrayFindLastReturn<T> {
return computed(() => {
const resolved = toValue(list).map(item => toValue(item));
return hasNativeFindLast ? resolved.findLast(fn) : findLast(resolved, fn);
});
}
@@ -0,0 +1,168 @@
import { describe, expect, it } from 'vitest';
import { ref } from 'vue';
import { useArrayIncludes } from '.';
describe(useArrayIncludes, () => {
it('returns true when the value is present', () => {
const list = ref([1, 2, 3, 4]);
const has = useArrayIncludes(list, 3);
expect(has.value).toBeTruthy();
});
it('returns false when the value is absent', () => {
const list = ref([1, 2, 3, 4]);
const has = useArrayIncludes(list, 5);
expect(has.value).toBeFalsy();
});
it('returns false for an empty array', () => {
const list = ref<number[]>([]);
const has = useArrayIncludes(list, 1);
expect(has.value).toBeFalsy();
});
it('updates reactively when the source array changes', () => {
const list = ref([1, 2, 3]);
const has = useArrayIncludes(list, 4);
expect(has.value).toBeFalsy();
list.value = [1, 4, 5];
expect(has.value).toBeTruthy();
list.value = [1, 2];
expect(has.value).toBeFalsy();
});
it('updates reactively when the searched value changes', () => {
const list = ref([1, 2, 3]);
const target = ref(2);
const has = useArrayIncludes(list, target);
expect(has.value).toBeTruthy();
target.value = 9;
expect(has.value).toBeFalsy();
});
it('unwraps reactive items', () => {
const list = [ref(1), ref(2), ref(3)];
const has = useArrayIncludes(list, 2);
expect(has.value).toBeTruthy();
});
it('reacts to changes in reactive items', () => {
const a = ref(1);
const b = ref(2);
const has = useArrayIncludes([a, b], 9);
expect(has.value).toBeFalsy();
b.value = 9;
expect(has.value).toBeTruthy();
});
it('accepts a getter as the source', () => {
const source = ref([1, 2, 3]);
const has = useArrayIncludes(() => source.value, 3);
expect(has.value).toBeTruthy();
source.value = [1, 2];
expect(has.value).toBeFalsy();
});
it('supports a custom comparator function', () => {
const list = ref([{ id: 1 }, { id: 2 }, { id: 3 }]);
const has = useArrayIncludes(list, 2, (element, value) => element.id === value);
expect(has.value).toBeTruthy();
const missing = useArrayIncludes(list, 9, (element, value) => element.id === value);
expect(missing.value).toBeFalsy();
});
it('passes index and array to the comparator', () => {
const list = ref(['a', 'b', 'c']);
const calls: Array<[string, string, number, number]> = [];
const has = useArrayIncludes(list, 'z', (element, value, index, array) => {
calls.push([element, value, index, array.length]);
return false;
});
expect(has.value).toBeFalsy();
expect(calls).toEqual([
['a', 'z', 0, 3],
['b', 'z', 1, 3],
['c', 'z', 2, 3],
]);
});
it('supports a key of T as the comparator', () => {
const list = ref([{ id: 1 }, { id: 2 }, { id: 3 }]);
const has = useArrayIncludes(list, 2, 'id');
expect(has.value).toBeTruthy();
const missing = useArrayIncludes(list, 9, 'id');
expect(missing.value).toBeFalsy();
});
it('reacts to changes when comparing by key', () => {
const list = ref([{ id: 1 }, { id: 2 }]);
const target = ref(2);
const has = useArrayIncludes(list, target, 'id');
expect(has.value).toBeTruthy();
target.value = 5;
expect(has.value).toBeFalsy();
list.value = [{ id: 5 }];
expect(has.value).toBeTruthy();
});
it('honors a positive fromIndex', () => {
const list = ref(['a', 'b', 'a']);
const fromZero = useArrayIncludes(list, 'a', { fromIndex: 0 });
expect(fromZero.value).toBeTruthy();
const fromTwo = useArrayIncludes(list, 'a', { fromIndex: 2 });
expect(fromTwo.value).toBeTruthy();
const fromThree = useArrayIncludes(list, 'a', { fromIndex: 3 });
expect(fromThree.value).toBeFalsy();
});
it('honors a negative fromIndex like Array.includes', () => {
const list = ref([1, 2, 3, 4, 5]);
const lastTwo = useArrayIncludes(list, 3, { fromIndex: -2 });
expect(lastTwo.value).toBeFalsy();
const lastThree = useArrayIncludes(list, 3, { fromIndex: -3 });
expect(lastThree.value).toBeTruthy();
// Negative index beyond the start clamps to 0.
const wayBack = useArrayIncludes(list, 1, { fromIndex: -100 });
expect(wayBack.value).toBeTruthy();
});
it('combines comparator and fromIndex in the options object', () => {
const list = ref([{ id: 1 }, { id: 2 }, { id: 1 }]);
const has = useArrayIncludes(list, 1, {
comparator: 'id',
fromIndex: 1,
});
expect(has.value).toBeTruthy();
const missing = useArrayIncludes(list, 2, {
comparator: 'id',
fromIndex: 2,
});
expect(missing.value).toBeFalsy();
});
it('uses strict equality by default', () => {
const list = ref<Array<number | string>>([1, 2, 3]);
const has = useArrayIncludes(list, '2');
expect(has.value).toBeFalsy();
});
it('matches the searched value when it is a reactive getter', () => {
const list = ref([10, 20, 30]);
const has = useArrayIncludes(list, () => 20);
expect(has.value).toBeTruthy();
});
});
@@ -0,0 +1,115 @@
import { computed, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
import { isObject, isString } from '@robonen/stdlib';
/**
* Comparator deciding whether an array element equals the searched value.
*/
export type UseArrayIncludesComparatorFn<T, V>
= (element: T, value: V, index: number, array: T[]) => boolean;
export interface UseArrayIncludesOptions<T, V> {
/**
* Index at which to start searching (negative counts from the end, like `Array.prototype.includes`).
*
* @default 0
*/
fromIndex?: number;
/**
* Custom comparator function, or a key of `T` to compare a single property by.
*/
comparator?: UseArrayIncludesComparatorFn<T, V> | keyof T;
}
export type UseArrayIncludesReturn = ComputedRef<boolean>;
function isArrayIncludesOptions<T, V>(value: unknown): value is UseArrayIncludesOptions<T, V> {
// isObject matches PLAIN objects only, so functions/keys never reach here.
return isObject(value) && ('fromIndex' in value || 'comparator' in value);
}
/**
* @name useArrayIncludes
* @category Array
* @description Reactive `Array.prototype.includes` with an optional comparator and `fromIndex`. The source array and its items may be reactive.
*
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
* @param {MaybeRefOrGetter<V>} value The value to search for (may be reactive)
* @param {UseArrayIncludesComparatorFn<T, V> | keyof T | UseArrayIncludesOptions<T, V>} [comparator] A comparator function, a key of `T` to compare by, or an options object with `comparator`/`fromIndex`
* @returns {UseArrayIncludesReturn} A computed boolean that is `true` when the value is found
*
* @example
* const list = ref([1, 2, 3, 4]);
* const hasThree = useArrayIncludes(list, 3); // true
*
* @example
* const list = ref([{ id: 1 }, { id: 2 }]);
* const hasTwo = useArrayIncludes(list, 2, 'id'); // compare by key
*
* @example
* const list = ref(['a', 'b', 'a']);
* const fromSecond = useArrayIncludes(list, 'a', { fromIndex: 1 }); // true
*
* @since 0.0.15
*/
export function useArrayIncludes<T, V = T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
value: MaybeRefOrGetter<V>,
comparator?: UseArrayIncludesComparatorFn<T, V>,
): UseArrayIncludesReturn;
export function useArrayIncludes<T, V = T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
value: MaybeRefOrGetter<V>,
comparator?: keyof T,
): UseArrayIncludesReturn;
export function useArrayIncludes<T, V = T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
value: MaybeRefOrGetter<V>,
options?: UseArrayIncludesOptions<T, V>,
): UseArrayIncludesReturn;
export function useArrayIncludes<T, V = T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
value: MaybeRefOrGetter<V>,
comparator?: UseArrayIncludesComparatorFn<T, V> | keyof T | UseArrayIncludesOptions<T, V>,
): UseArrayIncludesReturn {
let fromIndex = 0;
let resolved = comparator;
if (isArrayIncludesOptions<T, V>(resolved)) {
fromIndex = resolved.fromIndex ?? 0;
resolved = resolved.comparator;
}
// Resolve the comparator once instead of on every recompute.
let compare: UseArrayIncludesComparatorFn<T, V>;
if (isString(resolved) || typeof resolved === 'symbol' || typeof resolved === 'number') {
const key = resolved as keyof T;
compare = (element, searched) => element[key] === (searched as unknown);
}
else if (typeof resolved === 'function') {
compare = resolved;
}
else {
compare = (element, searched) => (element as unknown) === searched;
}
return computed(() => {
const array = toValue(list);
const searched = toValue(value);
const length = array.length;
// Resolve a negative / out-of-range fromIndex the same way Array.includes does.
let start = fromIndex < 0 ? length + fromIndex : fromIndex;
if (start < 0)
start = 0;
for (let index = start; index < length; index++) {
// `index` is bounded by `length`; `!` drops the index-access undefined.
if (compare(toValue(array[index]!), searched, index, array as T[]))
return true;
}
return false;
});
}
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest';
import { ref } from 'vue';
import { useArrayJoin } from '.';
describe(useArrayJoin, () => {
it('joins with the default comma separator', () => {
const list = ref(['a', 'b', 'c']);
const joined = useArrayJoin(list);
expect(joined.value).toBe('a,b,c');
});
it('joins with a static separator', () => {
const list = ref(['a', 'b', 'c']);
const joined = useArrayJoin(list, '-');
expect(joined.value).toBe('a-b-c');
});
it('recomputes when the source array changes', () => {
const list = ref(['a', 'b']);
const joined = useArrayJoin(list, '/');
expect(joined.value).toBe('a/b');
list.value = ['x', 'y', 'z'];
expect(joined.value).toBe('x/y/z');
});
it('reacts to a reactive separator', () => {
const list = ref(['a', 'b', 'c']);
const sep = ref('-');
const joined = useArrayJoin(list, sep);
expect(joined.value).toBe('a-b-c');
sep.value = ' | ';
expect(joined.value).toBe('a | b | c');
});
it('unwraps reactive items', () => {
const list = [ref('a'), ref('b'), ref('c')];
const joined = useArrayJoin(list, '-');
expect(joined.value).toBe('a-b-c');
});
it('reacts to changes in reactive items', () => {
const a = ref('a');
const list = [a, ref('b')];
const joined = useArrayJoin(list, '-');
expect(joined.value).toBe('a-b');
a.value = 'z';
expect(joined.value).toBe('z-b');
});
it('accepts a getter as the source list', () => {
const a = ref('a');
const b = ref('b');
const joined = useArrayJoin(() => [a.value, b.value], '-');
expect(joined.value).toBe('a-b');
a.value = 'z';
expect(joined.value).toBe('z-b');
});
it('returns an empty string for an empty array', () => {
const list = ref<string[]>([]);
const joined = useArrayJoin(list, '-');
expect(joined.value).toBe('');
});
it('returns the single element with no separator applied', () => {
const list = ref(['only']);
const joined = useArrayJoin(list, '-');
expect(joined.value).toBe('only');
});
it('stringifies non-string elements like native join', () => {
const list = ref([1, 2, 3]);
const joined = useArrayJoin(list, '+');
expect(joined.value).toBe('1+2+3');
});
it('renders null and undefined as empty strings like native join', () => {
const list = ref([null, 'a', undefined, 'b']);
const joined = useArrayJoin(list, ',');
expect(joined.value).toBe(',a,,b');
});
it('treats an empty-string separator as concatenation', () => {
const list = ref(['a', 'b', 'c']);
const joined = useArrayJoin(list, '');
expect(joined.value).toBe('abc');
});
it('joins a getter list of reactive items', () => {
const a = ref('a');
const b = ref('b');
const list = ref([a, b]);
const joined = useArrayJoin(() => list.value, '-');
expect(joined.value).toBe('a-b');
b.value = 'z';
expect(joined.value).toBe('a-z');
});
});
@@ -0,0 +1,42 @@
import { computed, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
export type UseArrayJoinReturn = ComputedRef<string>;
/**
* @name useArrayJoin
* @category Array
* @description Reactive `Array.prototype.join`, with an optional reactive separator.
*
* @param {MaybeRefOrGetter<MaybeRefOrGetter<unknown>[]>} list The source array (items can be reactive)
* @param {MaybeRefOrGetter<string>} [separator] A reactive separator placed between adjacent elements (defaults to `,`)
* @returns {UseArrayJoinReturn} A computed string of all elements joined; empty string when the array is empty
*
* @example
* const list = ref(['a', 'b', 'c']);
* const sep = ref('-');
* const joined = useArrayJoin(list, sep); // 'a-b-c'
*
* @since 0.0.15
*/
export function useArrayJoin(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<unknown>>>,
separator?: MaybeRefOrGetter<string>,
): UseArrayJoinReturn {
return computed(() => {
const resolved = toValue(list);
// `Array.prototype.join` already stringifies each element, but resolving
// reactive items first lets the computed track per-item ref dependencies.
let needsUnwrap = false;
for (const item of resolved) {
if (typeof item === 'function' || (typeof item === 'object' && item !== null && 'value' in item)) {
needsUnwrap = true;
break;
}
}
const source = needsUnwrap ? resolved.map(item => toValue(item)) : resolved;
return source.join(toValue(separator));
});
}
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { ref } from 'vue';
import { useArrayMap } from '.';
describe(useArrayMap, () => {
it('maps reactively', () => {
const list = ref([1, 2, 3]);
const doubled = useArrayMap(list, n => n * 2);
expect(doubled.value).toEqual([2, 4, 6]);
list.value = [4, 5];
expect(doubled.value).toEqual([8, 10]);
});
it('unwraps reactive items', () => {
const list = [ref(1), ref(2)];
const mapped = useArrayMap(list, n => n + 1);
expect(mapped.value).toEqual([2, 3]);
});
});
@@ -0,0 +1,24 @@
import { computed, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
/**
* @name useArrayMap
* @category Array
* @description Reactive `Array.prototype.map`.
*
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
* @param {(element: T, index: number, array: T[]) => U} fn Mapper
* @returns {ComputedRef<U[]>} The mapped array
*
* @example
* const list = ref([1, 2, 3]);
* const doubled = useArrayMap(list, n => n * 2); // [2, 4, 6]
*
* @since 0.0.15
*/
export function useArrayMap<T, U = T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
fn: (element: T, index: number, array: T[]) => U,
): ComputedRef<U[]> {
return computed(() => toValue(list).map(i => toValue(i)).map(fn));
}
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest';
import { ref } from 'vue';
import { useArrayReduce } from '.';
describe(useArrayReduce, () => {
it('reduces without an initial value', () => {
const list = ref([1, 2, 3, 4]);
const sum = useArrayReduce(list, (acc, n) => acc + n);
expect(sum.value).toBe(10);
});
it('reduces with an initial value', () => {
const list = ref([1, 2, 3, 4]);
const sum = useArrayReduce(list, (acc, n) => acc + n, 100);
expect(sum.value).toBe(110);
});
it('recomputes when the source array changes', () => {
const list = ref([1, 2, 3]);
const sum = useArrayReduce(list, (acc, n) => acc + n, 0);
expect(sum.value).toBe(6);
list.value = [10, 20];
expect(sum.value).toBe(30);
});
it('unwraps reactive items', () => {
const list = [ref(1), ref(2), ref(3)];
const sum = useArrayReduce(list, (acc, n) => acc + n, 0);
expect(sum.value).toBe(6);
});
it('reacts to a reactive initial value', () => {
const list = ref([1, 2, 3]);
const seed = ref(10);
const sum = useArrayReduce(list, (acc, n) => acc + n, seed);
expect(sum.value).toBe(16);
seed.value = 100;
expect(sum.value).toBe(106);
});
it('passes the current index to the reducer', () => {
const list = ref(['a', 'b', 'c']);
const indexed = useArrayReduce(
list,
(acc, value, index) => `${acc}${index}:${value};`,
'',
);
expect(indexed.value).toBe('0:a;1:b;2:c;');
});
it('supports a different accumulator type via initial value', () => {
const list = ref(['a', 'b', 'a', 'c', 'b']);
const counts = useArrayReduce(
list,
(acc: Record<string, number>, key) => {
acc[key] = (acc[key] ?? 0) + 1;
return acc;
},
() => ({}) as Record<string, number>,
);
expect(counts.value).toEqual({ a: 2, b: 2, c: 1 });
});
it('treats undefined as a valid initial value (not a missing seed)', () => {
const list = ref([1, 2]);
// With a real seed of `undefined`, the reducer runs for every element.
const calls: Array<[unknown, number]> = [];
const result = useArrayReduce<number, number | undefined>(
list,
(acc, n) => {
calls.push([acc, n]);
return n;
},
undefined,
);
expect(result.value).toBe(2);
expect(calls).toEqual([[undefined, 1], [1, 2]]);
});
it('throws on an empty array with no initial value (native reduce semantics)', () => {
const list = ref<number[]>([]);
const sum = useArrayReduce(list, (acc, n) => acc + n);
expect(() => sum.value).toThrow(TypeError);
});
it('returns the initial value for an empty array', () => {
const list = ref<number[]>([]);
const sum = useArrayReduce(list, (acc, n) => acc + n, 42);
expect(sum.value).toBe(42);
});
it('accepts a getter as the source list', () => {
const a = ref(1);
const b = ref(2);
const product = useArrayReduce(() => [a.value, b.value], (acc, n) => acc * n, 1);
expect(product.value).toBe(2);
a.value = 5;
expect(product.value).toBe(10);
});
});
@@ -0,0 +1,73 @@
import { computed, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
export type UseArrayReducer<PV, CV, R>
= (accumulator: PV, currentValue: CV, currentIndex: number) => R;
export type UseArrayReduceReturn<T> = ComputedRef<T>;
/**
* @name useArrayReduce
* @category Array
* @description Reactive `Array.prototype.reduce`, with an optional initial value.
*
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
* @param {UseArrayReducer<T, T, T>} reducer A reducer callback applied to each element
* @returns {UseArrayReduceReturn<T>} The reduced value
*
* @example
* const list = ref([1, 2, 3, 4]);
* const sum = useArrayReduce(list, (acc, n) => acc + n); // 10
*
* @since 0.0.15
*/
export function useArrayReduce<T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
reducer: UseArrayReducer<T, T, T>,
): UseArrayReduceReturn<T>;
/**
* @name useArrayReduce
* @category Array
* @description Reactive `Array.prototype.reduce`, with an optional initial value.
*
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
* @param {UseArrayReducer<U, T, U>} reducer A reducer callback applied to each element
* @param {MaybeRefOrGetter<U>} initialValue A reactive value to seed the accumulator with
* @returns {UseArrayReduceReturn<U>} The reduced value
*
* @example
* const list = ref([1, 2, 3, 4]);
* const sum = useArrayReduce(list, (acc, n) => acc + n, 100); // 110
*
* @since 0.0.15
*/
export function useArrayReduce<T, U>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
reducer: UseArrayReducer<U, T, U>,
initialValue: MaybeRefOrGetter<U>,
): UseArrayReduceReturn<U>;
export function useArrayReduce<T, U>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
reducer: UseArrayReducer<U, T, U>,
initialValue?: MaybeRefOrGetter<U>,
): UseArrayReduceReturn<U> {
const step = (
accumulator: U,
current: MaybeRefOrGetter<T>,
index: number,
): U => reducer(accumulator, toValue(current), index);
// Capture presence here (arguments.length, not a default value) so that an
// explicitly-passed `undefined` is still honoured as a real initial value.
const hasInitial = arguments.length >= 3;
return computed(() => {
const resolved = toValue(list);
return hasInitial
? resolved.reduce(step, toValue(initialValue as MaybeRefOrGetter<U>))
: (resolved as unknown as U[]).reduce(step as unknown as (a: U, c: U, i: number) => U);
});
}
@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest';
import { ref } from 'vue';
import { useArraySome } from '.';
describe(useArraySome, () => {
it('returns true when any element matches', () => {
const list = ref([1, 2, 3, 4]);
const hasEven = useArraySome(list, n => n % 2 === 0);
expect(hasEven.value).toBeTruthy();
});
it('returns false when no element matches', () => {
const list = ref([1, 3, 5, 7]);
const hasEven = useArraySome(list, n => n % 2 === 0);
expect(hasEven.value).toBeFalsy();
});
it('returns false for an empty array', () => {
const list = ref<number[]>([]);
const result = useArraySome(list, () => true);
expect(result.value).toBeFalsy();
});
it('updates reactively when the source array changes', () => {
const list = ref([1, 3, 5]);
const hasEven = useArraySome(list, n => n % 2 === 0);
expect(hasEven.value).toBeFalsy();
list.value = [1, 2, 5];
expect(hasEven.value).toBeTruthy();
list.value = [7, 9];
expect(hasEven.value).toBeFalsy();
});
it('unwraps reactive items', () => {
const list = [ref(1), ref(3), ref(4)];
const hasEven = useArraySome(list, n => n % 2 === 0);
expect(hasEven.value).toBeTruthy();
});
it('reacts to changes in reactive items', () => {
const a = ref(1);
const b = ref(3);
const list = [a, b];
const hasEven = useArraySome(list, n => n % 2 === 0);
expect(hasEven.value).toBeFalsy();
b.value = 4;
expect(hasEven.value).toBeTruthy();
});
it('accepts a getter as the source', () => {
const source = ref([1, 2, 3]);
const hasThree = useArraySome(() => source.value, n => n === 3);
expect(hasThree.value).toBeTruthy();
source.value = [1, 2];
expect(hasThree.value).toBeFalsy();
});
it('passes index and array to the predicate', () => {
const list = ref(['a', 'b', 'c']);
const calls: Array<[string, number, number]> = [];
const result = useArraySome(list, (element, index, array) => {
calls.push([element, index, array.length]);
return false;
});
expect(result.value).toBeFalsy();
expect(calls).toEqual([['a', 0, 3], ['b', 1, 3], ['c', 2, 3]]);
});
it('short-circuits on the first truthy result', () => {
const list = ref([1, 2, 3, 4]);
let visited = 0;
const result = useArraySome(list, (n) => {
visited++;
return n === 2;
});
expect(result.value).toBeTruthy();
expect(visited).toBe(2);
});
});
@@ -0,0 +1,30 @@
import { computed, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
export type UseArraySomeReturn = ComputedRef<boolean>;
/**
* @name useArraySome
* @category Array
* @description Reactive `Array.prototype.some`. The source array and its items may be reactive.
*
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
* @param {(element: T, index: number, array: MaybeRefOrGetter<T>[]) => unknown} fn Predicate to test each element
* @returns {UseArraySomeReturn} A computed boolean that is `true` if `fn` returns a truthy value for any element, otherwise `false`
*
* @example
* const list = ref([1, 2, 3, 4]);
* const hasEven = useArraySome(list, n => n % 2 === 0); // true
*
* @example
* const items = [ref(1), ref(3), ref(5)];
* const hasEven = useArraySome(items, n => n % 2 === 0); // false
*
* @since 0.0.15
*/
export function useArraySome<T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
fn: (element: T, index: number, array: Array<MaybeRefOrGetter<T>>) => unknown,
): UseArraySomeReturn {
return computed(() => toValue(list).some((element, index, array) => fn(toValue(element), index, array)));
}
@@ -0,0 +1,147 @@
import { describe, expect, it } from 'vitest';
import { effectScope, ref } from 'vue';
import { useArrayUnique } from '.';
describe(useArrayUnique, () => {
it('de-duplicates primitive values using strict identity', () => {
const list = ref([1, 2, 2, 3, 3, 3]);
const result = useArrayUnique(list);
expect(result.value).toEqual([1, 2, 3]);
});
it('preserves first-seen insertion order', () => {
const list = ref([3, 1, 3, 2, 1]);
const result = useArrayUnique(list);
expect(result.value).toEqual([3, 1, 2]);
});
it('distinguishes values of different types with === semantics', () => {
const list = ref<Array<number | string>>([1, '1', 1, '1']);
const result = useArrayUnique(list);
expect(result.value).toEqual([1, '1']);
});
it('treats NaN occurrences as a single unique value', () => {
const list = ref([Number.NaN, Number.NaN, 1]);
const result = useArrayUnique(list);
expect(result.value).toEqual([Number.NaN, 1]);
});
it('returns an empty array for an empty source', () => {
const list = ref<number[]>([]);
const result = useArrayUnique(list);
expect(result.value).toEqual([]);
});
it('updates reactively when the source array changes', () => {
const list = ref([1, 1, 2]);
const result = useArrayUnique(list);
expect(result.value).toEqual([1, 2]);
list.value = [3, 3, 3, 4];
expect(result.value).toEqual([3, 4]);
});
it('accepts a getter as the source', () => {
const source = ref([1, 2, 2]);
const result = useArrayUnique(() => source.value);
expect(result.value).toEqual([1, 2]);
source.value = [5, 5, 6];
expect(result.value).toEqual([5, 6]);
});
it('unwraps reactive items', () => {
const list = [ref(1), ref(1), ref(2)];
const result = useArrayUnique(list);
expect(result.value).toEqual([1, 2]);
});
it('reacts to changes in reactive items', () => {
const a = ref(1);
const b = ref(2);
const result = useArrayUnique([a, b]);
expect(result.value).toEqual([1, 2]);
b.value = 1;
expect(result.value).toEqual([1]);
});
it('de-duplicates by a key of T', () => {
const list = ref([{ id: 1 }, { id: 2 }, { id: 1 }]);
const result = useArrayUnique(list, 'id');
expect(result.value).toEqual([{ id: 1 }, { id: 2 }]);
});
it('keeps the first occurrence when de-duplicating by key', () => {
const list = ref([
{ id: 1, label: 'a' },
{ id: 1, label: 'b' },
{ id: 2, label: 'c' },
]);
const result = useArrayUnique(list, 'id');
expect(result.value).toEqual([
{ id: 1, label: 'a' },
{ id: 2, label: 'c' },
]);
});
it('de-duplicates by a key extractor function', () => {
const list = ref([
{ name: 'Ann' },
{ name: 'Bob' },
{ name: 'Ann' },
]);
const result = useArrayUnique(list, item => item.name);
expect(result.value).toEqual([{ name: 'Ann' }, { name: 'Bob' }]);
});
it('de-duplicates with a custom comparator function', () => {
const list = ref([1.1, 1.4, 2.2, 2.9, 3.0]);
const result = useArrayUnique(list, (a: number, b: number) => Math.floor(a) === Math.floor(b));
expect(result.value).toEqual([1.1, 2.2, 3.0]);
});
it('passes the resolved array to the comparator', () => {
const list = ref([1, 2, 2]);
const seen: number[] = [];
const result = useArrayUnique(list, (a: number, b: number, array: number[]) => {
seen.push(array.length);
return a === b;
});
expect(result.value).toEqual([1, 2]);
expect(seen.every(length => length === 3)).toBeTruthy();
});
it('reacts to changes when comparing by key', () => {
const list = ref([{ id: 1 }, { id: 2 }, { id: 1 }]);
const result = useArrayUnique(list, 'id');
expect(result.value).toEqual([{ id: 1 }, { id: 2 }]);
list.value = [{ id: 3 }, { id: 3 }, { id: 4 }];
expect(result.value).toEqual([{ id: 3 }, { id: 4 }]);
});
it('reacts to changes when using a comparator function', () => {
const list = ref([1.1, 1.9]);
const result = useArrayUnique(list, (a: number, b: number) => Math.floor(a) === Math.floor(b));
expect(result.value).toEqual([1.1]);
list.value = [1.1, 2.2, 2.9];
expect(result.value).toEqual([1.1, 2.2]);
});
it('works outside of a component instance (SSR-safe, no global access)', () => {
// The composable must not touch window/document/navigator: running it inside
// a bare effectScope (no component, no DOM globals needed) must succeed.
const scope = effectScope();
let result: ReturnType<typeof useArrayUnique<number>> | undefined;
scope.run(() => {
result = useArrayUnique(ref([1, 1, 2, 3, 3]));
});
expect(result?.value).toEqual([1, 2, 3]);
scope.stop();
});
});
@@ -0,0 +1,127 @@
import { computed, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
import { isString, unique } from '@robonen/stdlib';
/**
* Equality comparator deciding whether two array elements are duplicates.
*/
export type UseArrayUniqueComparatorFn<T>
= (a: T, b: T, array: T[]) => boolean;
/**
* Extracts the comparison key for an element. Two elements that produce the
* same key (via `===`/`Set` identity) are considered duplicates.
*/
export type UseArrayUniqueKeyFn<T>
= (element: T) => PropertyKey;
export type UseArrayUniqueReturn<T = unknown> = ComputedRef<T[]>;
/**
* @name useArrayUnique
* @category Array
* @description Reactive de-duplicated array. By default uses `Set` identity (`===`); an optional key of `T`, key extractor (both O(n)), or full comparator (O(n²)) customizes equality. The source array and its items may be reactive. First-seen insertion order is preserved.
*
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
* @param {UseArrayUniqueComparatorFn<T> | UseArrayUniqueKeyFn<T> | keyof T} [comparator] A custom equality comparator, a key extractor, or a key of `T` to de-duplicate by
* @returns {UseArrayUniqueReturn<T>} A computed array containing only the first occurrence of each unique element
*
* @example
* const list = ref([1, 2, 2, 3, 3, 3]);
* const uniq = useArrayUnique(list); // [1, 2, 3]
*
* @example
* const list = ref([{ id: 1 }, { id: 2 }, { id: 1 }]);
* const byId = useArrayUnique(list, 'id'); // [{ id: 1 }, { id: 2 }]
*
* @example
* const list = ref([{ id: 1 }, { id: 2 }, { id: 1 }]);
* const byKey = useArrayUnique(list, item => item.id); // [{ id: 1 }, { id: 2 }]
*
* @example
* const list = ref([1.1, 1.4, 2.2]);
* const byFloor = useArrayUnique(list, (a, b) => Math.floor(a) === Math.floor(b)); // [1.1, 2.2]
*
* @since 0.0.15
*/
export function useArrayUnique<T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
): UseArrayUniqueReturn<T>;
export function useArrayUnique<T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
comparator: keyof T,
): UseArrayUniqueReturn<T>;
export function useArrayUnique<T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
comparator: UseArrayUniqueKeyFn<T>,
): UseArrayUniqueReturn<T>;
export function useArrayUnique<T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
comparator: UseArrayUniqueComparatorFn<T>,
): UseArrayUniqueReturn<T>;
export function useArrayUnique<T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
comparator?: UseArrayUniqueComparatorFn<T> | UseArrayUniqueKeyFn<T> | keyof T,
): UseArrayUniqueReturn<T> {
// Resolve the comparison strategy once, not on every recompute.
// Key of T (string | number | symbol) -> O(n) first-seen-wins key de-dup.
if (isString(comparator) || typeof comparator === 'symbol' || typeof comparator === 'number') {
const key = comparator as keyof T;
return computed<T[]>(() => uniqueByKey(resolve(list), element => element[key] as PropertyKey));
}
if (typeof comparator === 'function') {
// A unary key extractor stays O(n); a binary comparator falls back to O(n²)
// pairwise comparison (unavoidable for arbitrary equality). Branch on arity.
if (comparator.length <= 1) {
const extractor = comparator as UseArrayUniqueKeyFn<T>;
return computed<T[]>(() => uniqueByKey(resolve(list), extractor));
}
const compare = comparator as UseArrayUniqueComparatorFn<T>;
return computed<T[]>(() => {
const array = resolve(list);
const result: T[] = [];
for (const value of array) {
if (!result.some(kept => compare(value, kept, array)))
result.push(value);
}
return result;
});
}
// Default: identity (`===`) de-dup via stdlib unique's Set fast path.
return computed<T[]>(() => unique(resolve(list)));
}
/**
* Resolves the (possibly reactive) list and each (possibly reactive) item.
*/
function resolve<T>(list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>): T[] {
return toValue(list).map(element => toValue(element));
}
/**
* O(n) de-duplication that keeps the FIRST element seen per extracted key
* (matching VueUse's first-occurrence semantics). stdlib `unique` is
* last-write-wins per key, so we track seen keys in a Set here instead.
*/
function uniqueByKey<T>(array: T[], extractor: UseArrayUniqueKeyFn<T>): T[] {
const seen = new Set<PropertyKey>();
const result: T[] = [];
for (const element of array) {
const key = extractor(element);
if (seen.has(key))
continue;
seen.add(key);
result.push(element);
}
return result;
}
@@ -0,0 +1,150 @@
import { describe, expect, it } from 'vitest';
import { isReactive, nextTick, reactive, ref } from 'vue';
import type { Ref } from 'vue';
import { useSorted } from '.';
describe(useSorted, () => {
it('returns a sorted copy with the default numeric compare', () => {
const source = ref([3, 1, 2]);
const sorted = useSorted(source);
expect(sorted.value).toEqual([1, 2, 3]);
});
it('does not mutate the source by default', () => {
const original = [3, 1, 2];
const source = ref(original);
const sorted = useSorted(source);
expect(sorted.value).toEqual([1, 2, 3]);
expect(source.value).toEqual([3, 1, 2]);
expect(sorted.value).not.toBe(source.value);
});
it('reacts to source changes', () => {
const source = ref([3, 1, 2]);
const sorted = useSorted(source);
expect(sorted.value).toEqual([1, 2, 3]);
source.value = [9, 5, 7, 1];
expect(sorted.value).toEqual([1, 5, 7, 9]);
});
it('supports a custom compare function as the second argument', () => {
const source = ref([{ age: 30 }, { age: 18 }, { age: 25 }]);
const sorted = useSorted(source, (a, b) => a.age - b.age);
expect(sorted.value.map(u => u.age)).toEqual([18, 25, 30]);
});
it('supports descending order via compare function', () => {
const source = ref([1, 2, 3]);
const sorted = useSorted(source, (a, b) => b - a);
expect(sorted.value).toEqual([3, 2, 1]);
});
it('accepts an options object as the second argument', () => {
const source = ref([3, 1, 2]);
const sorted = useSorted(source, { compareFn: (a, b) => b - a });
expect(sorted.value).toEqual([3, 2, 1]);
});
it('accepts a compare function plus an options object', () => {
const calls: number[] = [];
const sortFn = <T>(arr: T[], compareFn: (a: T, b: T) => number): T[] => {
calls.push(arr.length);
return [...arr].sort(compareFn);
};
const source = ref([3, 1, 2]);
const sorted = useSorted(source, (a, b) => a - b, { sortFn });
expect(sorted.value).toEqual([1, 2, 3]);
expect(calls.length).toBeGreaterThan(0);
});
it('is stable: equal elements keep their original relative order', () => {
const source = ref([
{ k: 1, id: 'a' },
{ k: 1, id: 'b' },
{ k: 0, id: 'c' },
{ k: 1, id: 'd' },
]);
const sorted = useSorted(source, (a, b) => a.k - b.k);
expect(sorted.value.map(x => x.id)).toEqual(['c', 'a', 'b', 'd']);
});
it('works with getter sources', () => {
const base = ref([5, 3, 4]);
const sorted = useSorted(() => base.value);
expect(sorted.value).toEqual([3, 4, 5]);
base.value = [2, 1];
expect(sorted.value).toEqual([1, 2]);
});
it('works with a plain (non-reactive) array source', () => {
const sorted = useSorted([3, 1, 2]);
expect(sorted.value).toEqual([1, 2, 3]);
});
it('handles empty and single-element arrays', () => {
expect(useSorted(ref<number[]>([])).value).toEqual([]);
expect(useSorted(ref([42])).value).toEqual([42]);
});
describe('dirty mode', () => {
it('sorts the source ref in place', async () => {
const source = ref([3, 1, 2]);
const result = useSorted(source, { dirty: true });
await nextTick();
expect(source.value).toEqual([1, 2, 3]);
expect(result).toBe(source);
});
it('re-sorts when the source changes', async () => {
const source = ref([3, 1, 2]);
useSorted(source, { dirty: true });
await nextTick();
expect(source.value).toEqual([1, 2, 3]);
source.value = [9, 4, 6];
await nextTick();
expect(source.value).toEqual([4, 6, 9]);
});
it('honors a custom compare function in dirty mode', async () => {
const source = ref([1, 2, 3]);
useSorted(source, (a, b) => b - a, { dirty: true });
await nextTick();
expect(source.value).toEqual([3, 2, 1]);
});
it('mutates a reactive array source in place via a getter', async () => {
const source = reactive([3, 1, 2]);
useSorted(() => source, { dirty: true });
await nextTick();
expect(isReactive(source)).toBeTruthy();
expect([...source]).toEqual([1, 2, 3]);
});
});
describe('writable result', () => {
it('writes back to the source ref when assigned (non-dirty)', () => {
const source = ref([3, 1, 2]);
const sorted = useSorted(source);
sorted.value = [10, 20];
expect(source.value).toEqual([10, 20]);
});
it('silently ignores writes when the source is a getter', () => {
const base = ref([3, 1, 2]);
const sorted = useSorted(() => base.value) as unknown as Ref<number[]>;
expect(() => {
sorted.value = [10, 20];
}).not.toThrow();
// getter source is unchanged
expect(base.value).toEqual([3, 1, 2]);
});
});
describe('SSR safety', () => {
it('does not touch any DOM global and works without a document', () => {
// useSorted is pure reactive computation; it must run identically in SSR.
const sorted = useSorted(ref([3, 1, 2]));
expect(sorted.value).toEqual([1, 2, 3]);
});
});
});
@@ -0,0 +1,151 @@
import { computed, isRef, toValue, watchEffect } from 'vue';
import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue';
import { isFunction } from '@robonen/stdlib';
export type UseSortedCompareFn<T = any>
= (a: T, b: T) => number;
export type UseSortedFn<T = any>
= (arr: T[], compareFn: UseSortedCompareFn<T>) => T[];
export interface UseSortedOptions<T = any> {
/**
* The sort algorithm to apply. Receives a copy of the array (or the source
* itself in `dirty` mode) and the resolved compare function.
*
* Defaults to a guaranteed-stable merge sort, so equal elements always keep
* their original relative order regardless of the JS engine.
*/
sortFn?: UseSortedFn<T>;
/**
* The compare function used to order two elements, matching the signature of
* `Array.prototype.sort`.
*
* @default (a, b) => a - b
*/
compareFn?: UseSortedCompareFn<T>;
/**
* Sort the source array in place instead of returning a sorted copy.
*
* When `true`, the returned ref is the source itself and its values are
* re-sorted whenever the source changes.
*
* @default false
*/
dirty?: boolean;
}
const defaultCompare: UseSortedCompareFn<number> = (a, b) => a - b;
/**
* Guaranteed-stable merge sort. Equal elements keep their original order on
* every engine, unlike the historically engine-dependent `Array.prototype.sort`.
*/
function stableSort<T>(array: T[], compareFn: UseSortedCompareFn<T>): T[] {
const length = array.length;
if (length < 2)
return array;
const middle = length >> 1;
const left = stableSort(array.slice(0, middle), compareFn);
const right = stableSort(array.slice(middle), compareFn);
const result: T[] = Array.from({ length });
let i = 0;
let l = 0;
let r = 0;
while (l < left.length && r < right.length) {
// Bounds are guaranteed by the loop condition; `!` drops the index-access undefined.
// `<= 0` keeps left (earlier) element first -> stability.
if (compareFn(left[l]!, right[r]!) <= 0)
result[i++] = left[l++]!;
else
result[i++] = right[r++]!;
}
while (l < left.length)
result[i++] = left[l++]!;
while (r < right.length)
result[i++] = right[r++]!;
return result;
}
const defaultSortFn: UseSortedFn = <T>(source: T[], compareFn: UseSortedCompareFn<T>): T[] => stableSort(source, compareFn);
/**
* @name useSorted
* @category Array
* @description Reactive, stable sorted copy of an array. Mirrors `Array.prototype.sort` but never mutates the source by default and guarantees stable ordering.
*
* @param {MaybeRefOrGetter<T[]>} source The source array (ref, getter, or plain array)
* @param {UseSortedCompareFn<T> | UseSortedOptions<T>} [compareFn] A compare function, or an options object
* @param {Omit<UseSortedOptions<T>, 'compareFn'>} [options] Extra options when the second argument is a compare function
* @returns {ComputedRef<T[]> | Ref<T[]>} A computed sorted copy (default), or the source ref when `dirty` is `true`
*
* @example
* const list = ref([3, 1, 2]);
* const sorted = useSorted(list); // [1, 2, 3]
*
* @example
* // custom compare function
* const users = ref([{ age: 30 }, { age: 18 }]);
* const byAge = useSorted(users, (a, b) => a.age - b.age);
*
* @example
* // sort the source in place
* const list = ref([3, 1, 2]);
* useSorted(list, { dirty: true });
* // list.value is now [1, 2, 3]
*
* @since 0.0.15
*/
export function useSorted<T = any>(source: Ref<T[]>, compareFn?: UseSortedCompareFn<T>): Ref<T[]>;
export function useSorted<T = any>(source: MaybeRefOrGetter<T[]>, compareFn?: UseSortedCompareFn<T>): ComputedRef<T[]>;
export function useSorted<T = any>(source: Ref<T[]>, options?: UseSortedOptions<T>): Ref<T[]>;
export function useSorted<T = any>(source: MaybeRefOrGetter<T[]>, options?: UseSortedOptions<T>): ComputedRef<T[]>;
export function useSorted<T = any>(source: Ref<T[]>, compareFn?: UseSortedCompareFn<T>, options?: Omit<UseSortedOptions<T>, 'compareFn'>): Ref<T[]>;
export function useSorted<T = any>(source: MaybeRefOrGetter<T[]>, compareFn?: UseSortedCompareFn<T>, options?: Omit<UseSortedOptions<T>, 'compareFn'>): ComputedRef<T[]>;
export function useSorted<T = any>(
source: MaybeRefOrGetter<T[]>,
compareFnOrOptions?: UseSortedCompareFn<T> | UseSortedOptions<T>,
maybeOptions?: Omit<UseSortedOptions<T>, 'compareFn'>,
): ComputedRef<T[]> | Ref<T[]> {
let compareFn: UseSortedCompareFn<T> = defaultCompare as UseSortedCompareFn<T>;
let options: UseSortedOptions<T> = {};
if (isFunction(compareFnOrOptions)) {
compareFn = compareFnOrOptions;
options = maybeOptions ?? {};
}
else if (compareFnOrOptions) {
options = compareFnOrOptions;
compareFn = options.compareFn ?? (defaultCompare as UseSortedCompareFn<T>);
}
const {
dirty = false,
sortFn = defaultSortFn,
} = options;
if (!dirty) {
return computed<T[]>({
get: () => sortFn([...toValue(source)], compareFn),
set: (value) => {
if (isRef(source))
(source as Ref<T[]>).value = value;
},
});
}
watchEffect(() => {
const result = sortFn(toValue(source), compareFn);
if (isRef(source))
(source as Ref<T[]>).value = result;
else
(toValue(source)).splice(0, toValue(source).length, ...result);
});
return source as Ref<T[]>;
}