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,99 @@
import { describe, expect, it } from 'vitest';
import { computed, isRef, ref } from 'vue';
import type { Ref } from 'vue';
import { extendRef } from '.';
describe(extendRef, () => {
it('returns the same ref instance', () => {
const source = ref('content');
const extended = extendRef(source, { foo: 'bar' });
expect(extended).toBe(source);
expect(isRef(extended)).toBeTruthy();
});
it('keeps the ref value accessible and writable', () => {
const extended = extendRef(ref('content'), { foo: 'bar' });
expect(extended.value).toBe('content');
extended.value = 'updated';
expect(extended.value).toBe('updated');
});
it('attaches static (non-ref) properties', () => {
const extended = extendRef(ref(0), { foo: 'bar', n: 1 });
expect(extended.foo).toBe('bar');
expect(extended.n).toBe(1);
});
it('never overwrites the ref value via a "value" key in extend', () => {
const extended = extendRef(ref('keep'), { value: 'overwrite' });
expect(extended.value).toBe('keep');
});
it('unwraps ref-valued properties by default (read)', () => {
const count = ref(0);
const extended = extendRef(count, { double: computed(() => count.value * 2) });
expect(extended.double).toBe(0);
count.value = 4;
expect(extended.double).toBe(8);
});
it('unwraps ref-valued properties with two-way write', () => {
const inner = ref(1);
// `unwrap` (default true) auto-`.value`s the property at runtime, so it reads/writes as a number.
const extended = extendRef(ref(0), { inner }) as unknown as Ref<number> & { inner: number };
expect(extended.inner).toBe(1);
extended.inner = 5;
expect(inner.value).toBe(5);
expect(extended.inner).toBe(5);
});
it('keeps refs as refs when unwrap is false', () => {
const inner = ref(1);
// With `unwrap: false` the property stays a real ref at runtime.
const extended = extendRef(ref(0), { inner }, { unwrap: false }) as unknown as Ref<number> & { inner: Ref<number> };
expect(isRef(extended.inner)).toBeTruthy();
expect(extended.inner.value).toBe(1);
inner.value = 2;
expect(extended.inner.value).toBe(2);
});
it('extended properties are non-enumerable by default', () => {
const extended = extendRef(ref(0), { foo: 'bar' });
expect(Object.keys(extended)).not.toContain('foo');
expect(extended.foo).toBe('bar');
});
it('extended properties become enumerable with enumerable: true', () => {
const extended = extendRef(ref(0), { foo: 'bar' }, { enumerable: true });
expect(Object.keys(extended)).toContain('foo');
});
it('enumerable applies to unwrapped ref properties too', () => {
const extended = extendRef(ref(0), { inner: ref(1) }, { enumerable: true });
expect(Object.keys(extended)).toContain('inner');
expect(extended.inner).toBe(1);
});
it('supports multiple properties of mixed kinds', () => {
const r = ref(2);
const extended = extendRef(ref('x'), {
label: 'tag',
live: r,
frozen: 99,
});
expect(extended.label).toBe('tag');
expect(extended.live).toBe(2);
expect(extended.frozen).toBe(99);
r.value = 3;
expect(extended.live).toBe(3);
});
it('remains reactive after extension (value tracked by computed)', () => {
const source = ref(1);
const extended = extendRef(source, { meta: 'm' });
const derived = computed(() => extended.value * 10);
expect(derived.value).toBe(10);
extended.value = 5;
expect(derived.value).toBe(50);
});
});
@@ -0,0 +1,90 @@
import { isRef } from 'vue';
import type { Ref, ShallowUnwrapRef } from 'vue';
export interface ExtendRefOptions<Unwrap extends boolean = boolean> {
/**
* Whether the extended properties are enumerable.
*
* @default false
*/
enumerable?: boolean;
/**
* Whether to unwrap (auto-`.value`) extended properties that are themselves refs.
*
* @default true
*/
unwrap?: Unwrap;
}
export type ExtendRefReturn<R extends Ref<unknown>, Extend extends object, Unwrap extends boolean>
= Unwrap extends false ? (R & ShallowUnwrapRef<Extend>) : (R & Extend);
/**
* @name extendRef
* @category Reactivity
* @description Attach extra (optionally reactive) attributes to a ref while keeping it a usable ref.
*
* @param {Ref<T>} ref The ref to extend
* @param {object} extend The properties to attach; ref-valued props are unwrapped by default
* @param {ExtendRefOptions} [options={}] `enumerable` (default `false`) and `unwrap` (default `true`)
* @returns {Ref<T> & Extend} The same ref instance, now carrying the extended properties
*
* @example
* const myRef = ref('content');
* const extended = extendRef(myRef, { foo: 'bar' });
* extended.value; // 'content'
* extended.foo; // 'bar'
*
* @example
* // reactive extension: unwrapped two-way by default
* const count = ref(0);
* const extended = extendRef(count, { double: computed(() => count.value * 2) });
* extended.double; // 0 (no .value needed)
*
* @example
* // keep refs as refs with unwrap: false
* const extended = extendRef(ref(0), { inner: ref(1) }, { unwrap: false });
* extended.inner.value; // 1
*
* @since 0.0.15
*/
export function extendRef<R extends Ref<unknown>, Extend extends object, Options extends ExtendRefOptions<false>>(ref: R, extend: Extend, options: Options): R & ShallowUnwrapRef<Extend>;
export function extendRef<R extends Ref<unknown>, Extend extends object, Options extends ExtendRefOptions>(ref: R, extend: Extend, options?: Options): R & Extend;
export function extendRef<R extends Ref<unknown>, Extend extends object>(
ref: R,
extend: Extend,
options: ExtendRefOptions = {},
): R & Extend {
const { enumerable = false, unwrap = true } = options;
for (const key in extend) {
if (key === 'value')
continue;
const value = extend[key];
if (unwrap && isRef(value)) {
Object.defineProperty(ref, key, {
get() {
return value.value;
},
set(v) {
value.value = v;
},
enumerable,
configurable: true,
});
}
else {
Object.defineProperty(ref, key, {
value,
enumerable,
configurable: true,
writable: true,
});
}
}
return ref as R & Extend;
}