feat(vue): expand @robonen/vue composable collection
Composables, tests, category barrels, and README for @robonen/vue.
This commit is contained in:
@@ -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[]>;
|
||||
}
|
||||
Reference in New Issue
Block a user