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,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;
});
}