feat(monorepo): migrate vue packages and apply oxlint refactors

This commit is contained in:
2026-03-07 18:07:22 +07:00
parent abd6605db3
commit 41d5e18f6b
286 changed files with 10295 additions and 5028 deletions
@@ -0,0 +1,3 @@
export * from './unrefElement';
export * from './useForwardExpose';
export * from './useTemplateRefsList';
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import { computed, defineComponent, nextTick, ref, shallowRef } from 'vue';
import { mount } from '@vue/test-utils';
import { unrefElement } from '.';
describe(unrefElement, () => {
it('returns a plain element when passed a raw element', () => {
const htmlEl = document.createElement('div');
const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
expect(unrefElement(htmlEl)).toBe(htmlEl);
expect(unrefElement(svgEl)).toBe(svgEl);
});
it('returns element when passed a ref or shallowRef to an element', () => {
const el = document.createElement('div');
const elRef = ref<HTMLElement | null>(el);
const shallowElRef = shallowRef<HTMLElement | null>(el);
expect(unrefElement(elRef)).toBe(el);
expect(unrefElement(shallowElRef)).toBe(el);
});
it('returns element when passed a computed ref or getter function', () => {
const el = document.createElement('div');
const computedElRef = computed(() => el);
const elGetter = () => el;
expect(unrefElement(computedElRef)).toBe(el);
expect(unrefElement(elGetter)).toBe(el);
});
it('returns component $el when passed a component instance', async () => {
const Child = defineComponent({
template: `<span class="child-el">child</span>`,
});
const Parent = defineComponent({
components: { Child },
template: `<Child ref="childRef" />`,
});
const wrapper = mount(Parent);
await nextTick();
const childInstance = (wrapper.vm as any).$refs.childRef;
const result = unrefElement(childInstance);
expect(result).toBe(childInstance.$el);
expect((result as HTMLElement).classList.contains('child-el')).toBeTruthy();
});
it('handles null and undefined values', () => {
expect(unrefElement(undefined)).toBe(undefined);
expect(unrefElement(null)).toBe(null);
expect(unrefElement(ref<null>(null))).toBe(null);
expect(unrefElement(ref<undefined>(undefined))).toBe(undefined);
expect(unrefElement(() => null)).toBe(null);
expect(unrefElement(() => undefined)).toBe(undefined);
});
});
@@ -0,0 +1,33 @@
import type { ComponentPublicInstance, MaybeRef, MaybeRefOrGetter } from 'vue';
import { toValue } from 'vue';
export type VueInstance = ComponentPublicInstance;
export type MaybeElement = HTMLElement | SVGElement | VueInstance | undefined | null;
export type MaybeElementRef<El extends MaybeElement = MaybeElement> = MaybeRef<El>;
export type MaybeComputedElementRef<El extends MaybeElement = MaybeElement> = MaybeRefOrGetter<El>;
export type UnRefElementReturn<T extends MaybeElement = MaybeElement> = T extends VueInstance ? Exclude<MaybeElement, VueInstance> : T | undefined;
/**
* @name unrefElement
* @category Component
* @description Unwraps a Vue element reference to get the underlying instance or DOM element.
*
* @param {MaybeComputedElementRef<El>} elRef - The element reference to unwrap.
* @returns {UnRefElementReturn<El>} - The unwrapped element or undefined.
*
* @example
* const element = useTemplateRef<HTMLElement>('element');
* const result = unrefElement(element); // result is the element instance
*
* @example
* const component = useTemplateRef<Component>('component');
* const result = unrefElement(component); // result is the component instance
*
* @since 0.0.11
*/
export function unrefElement<El extends MaybeElement>(elRef: MaybeComputedElementRef<El>): UnRefElementReturn<El> {
const plain = toValue(elRef);
return (plain as VueInstance)?.$el ?? plain;
}
@@ -0,0 +1,276 @@
import { describe, expect, it } from 'vitest';
import type { UseForwardExposeReturn } from '.';
import { defineComponent, nextTick, ref } from 'vue';
import { mount } from '@vue/test-utils';
import { useForwardExpose } from '.';
describe(useForwardExpose, () => {
it('returns forwardRef, currentRef, and currentElement', () => {
let result!: UseForwardExposeReturn<any>;
const Component = defineComponent({
setup() {
result = useForwardExpose();
return {};
},
template: `<div>test</div>`,
});
mount(Component);
expect(result.forwardRef).toBeTypeOf('function');
expect(result.currentRef).toBeDefined();
expect(result.currentElement).toBeDefined();
});
it('exposes parent props on instance.exposed', () => {
const Component = defineComponent({
props: {
label: { type: String, default: 'hello' },
},
setup() {
useForwardExpose();
return {};
},
template: `<div>{{ label }}</div>`,
});
const wrapper = mount(Component, { props: { label: 'world' } });
expect(wrapper.vm.$.exposed).toBeDefined();
expect(wrapper.vm.$.exposed!.label).toBe('world');
});
it('exposes $el on instance.exposed', () => {
const Component = defineComponent({
setup() {
useForwardExpose();
return {};
},
template: `<div class="root">content</div>`,
});
const wrapper = mount(Component);
expect(wrapper.vm.$.exposed).toBeDefined();
expect(wrapper.vm.$.exposed!.$el).toBeInstanceOf(HTMLDivElement);
expect(wrapper.vm.$.exposed!.$el.classList.contains('root')).toBeTruthy();
});
it('forwardRef with a DOM element updates $el', async () => {
let result!: UseForwardExposeReturn<any>;
const Component = defineComponent({
setup() {
result = useForwardExpose();
return {};
},
template: `<div><span class="inner">inner</span></div>`,
});
const wrapper = mount(Component);
const innerSpan = wrapper.find('.inner').element;
result.forwardRef(innerSpan as Element);
await nextTick();
expect(result.currentRef.value).toBe(innerSpan);
expect(wrapper.vm.$.exposed!.$el).toBe(innerSpan);
});
it('forwardRef with a child component instance copies child exposed', async () => {
const Child = defineComponent({
setup(_, { expose }) {
const childValue = ref('from-child');
expose({ childValue });
return { childValue };
},
template: `<span class="child">child</span>`,
});
let result!: UseForwardExposeReturn<any>;
const Parent = defineComponent({
components: { Child },
setup() {
result = useForwardExpose();
return { forwardRef: result.forwardRef };
},
template: `<Child :ref="forwardRef" />`,
});
const wrapper = mount(Parent);
await nextTick();
// The parent's exposed should contain the child's exposed ref
expect(wrapper.vm.$.exposed).toBeDefined();
expect(wrapper.vm.$.exposed!.childValue).toEqual(ref('from-child'));
});
it('forwardRef with null clears currentRef without error', async () => {
let result!: UseForwardExposeReturn<any>;
const Component = defineComponent({
setup() {
result = useForwardExpose();
return {};
},
template: `<div>test</div>`,
});
mount(Component);
expect(() => result.forwardRef(null)).not.toThrow();
expect(result.currentRef.value).toBeNull();
});
it('merges prior expose bindings', () => {
const Component = defineComponent({
setup(_, { expose }) {
const custom = ref(42);
expose({ custom });
useForwardExpose();
return {};
},
template: `<div>test</div>`,
});
const wrapper = mount(Component);
expect(wrapper.vm.$.exposed).toBeDefined();
expect(wrapper.vm.$.exposed!.custom).toEqual(ref(42));
expect(wrapper.vm.$.exposed!.$el).toBeDefined();
});
it('currentElement resolves to HTMLElement for element ref', async () => {
let result!: UseForwardExposeReturn<any>;
const Component = defineComponent({
setup() {
result = useForwardExpose();
return {};
},
template: `<div class="resolved">content</div>`,
});
const wrapper = mount(Component);
const el = wrapper.find('.resolved').element;
result.forwardRef(el as Element);
await nextTick();
expect(result.currentElement.value).toBe(el);
});
it('switching child components updates exposed correctly', async () => {
const ChildA = defineComponent({
setup(_, { expose }) {
const a = ref('value-a');
expose({ a });
return { a };
},
template: `<span class="a">A</span>`,
});
const ChildB = defineComponent({
setup(_, { expose }) {
const b = ref('value-b');
expose({ b });
return { b };
},
template: `<span class="b">B</span>`,
});
let result!: UseForwardExposeReturn<any>;
const Parent = defineComponent({
components: { ChildA, ChildB },
setup() {
const showA = ref(true);
result = useForwardExpose();
return { showA, forwardRef: result.forwardRef };
},
template: `
<ChildA v-if="showA" :ref="forwardRef" />
<ChildB v-else :ref="forwardRef" />
`,
});
const wrapper = mount(Parent);
await nextTick();
// Initially ChildA is rendered
expect(wrapper.vm.$.exposed!.a).toEqual(ref('value-a'));
// Switch to ChildB
wrapper.vm.showA = false;
await nextTick();
// ChildB's exposed should be available
expect(wrapper.vm.$.exposed!.b).toEqual(ref('value-b'));
});
it('$el remains correct after switching from component to element', async () => {
const Child = defineComponent({
setup(_, { expose }) {
expose({ test: ref(1) });
return {};
},
template: `<span class="child-el">child</span>`,
});
let result!: UseForwardExposeReturn<any>;
const Parent = defineComponent({
components: { Child },
setup() {
result = useForwardExpose();
return { forwardRef: result.forwardRef };
},
template: `<Child :ref="forwardRef" />`,
});
const wrapper = mount(Parent);
await nextTick();
expect(wrapper.vm.$.exposed!.test).toEqual(ref(1));
// Now forward to a plain DOM element — $el must update on instance.exposed
const div = document.createElement('div');
result.forwardRef(div);
await nextTick();
expect(wrapper.vm.$.exposed!.$el).toBe(div);
});
it('parent props remain accessible after child forwarding', async () => {
const Child = defineComponent({
setup(_, { expose }) {
expose({ childProp: ref('child') });
return {};
},
template: `<span>child</span>`,
});
let result!: UseForwardExposeReturn<any>;
const Parent = defineComponent({
components: { Child },
props: {
parentLabel: { type: String, default: 'parent' },
},
setup() {
result = useForwardExpose();
return { forwardRef: result.forwardRef };
},
template: `<Child :ref="forwardRef" />`,
});
const wrapper = mount(Parent, { props: { parentLabel: 'test' } });
await nextTick();
// Both parent props and child exposed should be accessible
expect(wrapper.vm.$.exposed!.parentLabel).toBe('test');
expect(wrapper.vm.$.exposed!.childProp).toEqual(ref('child'));
});
});
@@ -0,0 +1,128 @@
import type { ComponentPublicInstance, Ref } from 'vue';
import { computed, getCurrentInstance, shallowRef } from 'vue';
import type { MaybeElement } from '../unrefElement';
import { unrefElement } from '../unrefElement';
/** Set of non-element node names that should be skipped when resolving `$el` */
const NON_ELEMENT_NODES = new Set(['#text', '#comment']);
export interface UseForwardExposeReturn<T extends ComponentPublicInstance> {
/** Callback to set as `:ref` — forwards child's exposed API and `$el` through the parent */
forwardRef: (ref: T | MaybeElement) => void;
/** Reactive reference to the forwarded element or component instance */
currentRef: Ref<T | MaybeElement>;
/** Computed property resolving to the underlying `HTMLElement`, skipping text/comment nodes */
currentElement: Readonly<Ref<HTMLElement | undefined>>;
}
/**
* @name useForwardExpose
* @category Component
* @description Forwards a child component's exposed API and DOM element (`$el`) through
* the parent component. Useful for wrapper / headless components that need to transparently
* proxy the inner component's ref to the consumer.
*
* Merges the parent's own props and any prior `expose()` bindings onto `instance.exposed`,
* then updates them when `forwardRef` is called with a child element or component instance.
*
* @returns {UseForwardExposeReturn<T>} An object with `forwardRef`, `currentRef`, and `currentElement`
*
* @example
* const { forwardRef, currentElement } = useForwardExpose();
* // Template: <ChildComponent :ref="forwardRef" />
*
* @example
* const { forwardRef, currentRef } = useForwardExpose<InstanceType<typeof MyInput>>();
* // Template: <MyInput :ref="forwardRef" />
* // currentRef.value exposes MyInput's public API
*
* @since 0.0.14
*/
export function useForwardExpose<T extends ComponentPublicInstance>(): UseForwardExposeReturn<T> {
const instance = getCurrentInstance()!;
const currentRef = shallowRef<T | MaybeElement>();
const currentElement = computed<HTMLElement | undefined>(() => {
// @ts-expect-error — $el exists on component instances but not on HTMLElement/SVGElement
const el = currentRef.value?.$el;
return NON_ELEMENT_NODES.has(el?.nodeName)
? (el.nextElementSibling as HTMLElement | undefined) ?? undefined
: (unrefElement(currentRef) as HTMLElement | undefined);
});
// localExpose should only be assigned once else will create infinite loop
const localExpose = instance.exposed;
const ret: Record<string, any> = {};
// Collect all property descriptors in a single pass
const descriptors: PropertyDescriptorMap = {};
for (const key in instance.props) {
descriptors[key] = {
enumerable: true,
configurable: true,
get: () => instance.props[key],
};
}
if (localExpose && Object.keys(localExpose).length > 0) {
for (const key in localExpose) {
descriptors[key] = {
enumerable: true,
configurable: true,
get: () => localExpose[key],
};
}
}
descriptors['$el'] = {
enumerable: true,
configurable: true,
get: () => instance.vnode.el,
};
Object.defineProperties(ret, descriptors);
instance.exposed = ret;
function forwardRef(ref: T | MaybeElement) {
currentRef.value = ref;
if (!ref) return;
const $elDescriptor: PropertyDescriptor = {
enumerable: true,
configurable: true,
get: () => (ref instanceof Element ? ref : ref.$el),
};
// Keep ret in sync — it's the source of descriptors for future rebuilds
Object.defineProperty(ret, '$el', $elDescriptor);
// Also update current instance.exposed if it has diverged from ret
if (instance.exposed && instance.exposed !== ret) {
Object.defineProperty(instance.exposed, '$el', $elDescriptor);
}
if (!(ref instanceof Element) && !Object.prototype.hasOwnProperty.call(ref, '$el')) {
const childExposed = ref.$.exposed;
if (childExposed) {
// Copy descriptors from ret (includes props, prior expose, $el)
const allDescriptors = Object.getOwnPropertyDescriptors(ret);
allDescriptors['$el'] = $elDescriptor;
for (const key in childExposed) {
allDescriptors[key] = {
enumerable: true,
configurable: true,
get: () => childExposed[key],
};
}
instance.exposed = Object.defineProperties({}, allDescriptors);
}
}
}
return { forwardRef, currentRef, currentElement };
}
@@ -0,0 +1,141 @@
import { describe, expect, it } from 'vitest';
import { defineComponent, nextTick, ref } from 'vue';
import { mount } from '@vue/test-utils';
import { useTemplateRefsList } from '.';
describe(useTemplateRefsList, () => {
it('collects elements rendered with v-for', async () => {
const Component = defineComponent({
setup() {
const items = ref([1, 2, 3]);
const { refs, set } = useTemplateRefsList<HTMLDivElement>();
return { items, refs, set };
},
template: `<div v-for="item in items" :key="item" :ref="set">{{ item }}</div>`,
});
const wrapper = mount(Component);
await nextTick();
expect(wrapper.vm.refs).toHaveLength(3);
expect(wrapper.vm.refs[0]).toBeInstanceOf(HTMLDivElement);
expect(wrapper.vm.refs[1]).toBeInstanceOf(HTMLDivElement);
expect(wrapper.vm.refs[2]).toBeInstanceOf(HTMLDivElement);
});
it('updates refs when items are added', async () => {
const Component = defineComponent({
setup() {
const items = ref([1, 2]);
const { refs, set } = useTemplateRefsList<HTMLDivElement>();
return { items, refs, set };
},
template: `<div v-for="item in items" :key="item" :ref="set">{{ item }}</div>`,
});
const wrapper = mount(Component);
await nextTick();
expect(wrapper.vm.refs).toHaveLength(2);
wrapper.vm.items.push(3);
await nextTick();
expect(wrapper.vm.refs).toHaveLength(3);
});
it('updates refs when items are removed', async () => {
const Component = defineComponent({
setup() {
const items = ref([1, 2, 3]);
const { refs, set } = useTemplateRefsList<HTMLDivElement>();
return { items, refs, set };
},
template: `<div v-for="item in items" :key="item" :ref="set">{{ item }}</div>`,
});
const wrapper = mount(Component);
await nextTick();
expect(wrapper.vm.refs).toHaveLength(3);
wrapper.vm.items.splice(0, 1);
await nextTick();
expect(wrapper.vm.refs).toHaveLength(2);
});
it('returns empty array when no elements are rendered', async () => {
const Component = defineComponent({
setup() {
const items = ref<number[]>([]);
const { refs, set } = useTemplateRefsList<HTMLDivElement>();
return { items, refs, set };
},
template: `<div><span v-for="item in items" :key="item" :ref="set">{{ item }}</span></div>`,
});
const wrapper = mount(Component);
await nextTick();
expect(wrapper.vm.refs).toHaveLength(0);
});
it('unwraps component instances to their root elements', async () => {
const Child = defineComponent({
template: `<span class="child">child</span>`,
});
const Parent = defineComponent({
components: { Child },
setup() {
const items = ref([1, 2]);
const { refs, set } = useTemplateRefsList<HTMLSpanElement>();
return { items, refs, set };
},
template: `<div><Child v-for="item in items" :key="item" :ref="set" /></div>`,
});
const wrapper = mount(Parent);
await nextTick();
expect(wrapper.vm.refs).toHaveLength(2);
expect(wrapper.vm.refs[0]).toBeInstanceOf(HTMLSpanElement);
expect(wrapper.vm.refs[0]!.classList.contains('child')).toBeTruthy();
});
it('preserves element order matching v-for order', async () => {
const Component = defineComponent({
setup() {
const items = ref(['a', 'b', 'c']);
const { refs, set } = useTemplateRefsList<HTMLDivElement>();
return { items, refs, set };
},
template: `<div v-for="item in items" :key="item" :ref="set" :data-item="item">{{ item }}</div>`,
});
const wrapper = mount(Component);
await nextTick();
expect(wrapper.vm.refs[0]!.dataset.item).toBe('a');
expect(wrapper.vm.refs[1]!.dataset.item).toBe('b');
expect(wrapper.vm.refs[2]!.dataset.item).toBe('c');
});
it('handles complete list replacement', async () => {
const Component = defineComponent({
setup() {
const items = ref([1, 2, 3]);
const { refs, set } = useTemplateRefsList<HTMLDivElement>();
return { items, refs, set };
},
template: `<div v-for="item in items" :key="item" :ref="set" :data-item="item">{{ item }}</div>`,
});
const wrapper = mount(Component);
await nextTick();
expect(wrapper.vm.refs).toHaveLength(3);
wrapper.vm.items = [4, 5];
await nextTick();
expect(wrapper.vm.refs).toHaveLength(2);
expect(wrapper.vm.refs[0]!.dataset.item).toBe('4');
expect(wrapper.vm.refs[1]!.dataset.item).toBe('5');
});
});
@@ -0,0 +1,72 @@
import { onBeforeUpdate, onMounted, onUpdated, readonly, shallowRef } from 'vue';
import type { DeepReadonly, ShallowRef } from 'vue';
import type { MaybeElement } from '../unrefElement';
import { unrefElement } from '../unrefElement';
export interface UseTemplateRefsListReturn<El extends Element> {
/** Reactive readonly array of collected template refs */
refs: DeepReadonly<ShallowRef<El[]>>;
/** Ref setter function — bind via `:ref="set"` in templates */
set: (el: MaybeElement) => void;
}
/**
* @name useTemplateRefsList
* @category Component
* @description Collects a dynamic list of template refs for use with `v-for`.
* Automatically clears the list before each component update and repopulates it
* with fresh element references. Handles both plain DOM elements and Vue component
* instances (unwraps `$el`).
*
* Uses a non-reactive buffer internally to collect refs during the render cycle,
* then flushes to a `shallowRef` in `onMounted`/`onUpdated` to avoid triggering
* recursive update loops.
*
* @returns {UseTemplateRefsListReturn<El>} An object with a reactive `refs` array and a `set` function
*
* @example
* const { refs, set } = useTemplateRefsList<HTMLDivElement>();
* // Template: <div v-for="item in items" :key="item.id" :ref="set" />
* // refs.value contains all rendered div elements
*
* @since 0.0.14
*/
export function useTemplateRefsList<El extends Element = Element>(): UseTemplateRefsListReturn<El> {
const refs = shallowRef<El[]>([]);
let buffer: El[] = [];
const set = (el: MaybeElement) => {
const plain = unrefElement(el);
if (plain)
buffer.push(plain as unknown as El);
};
const flush = () => {
buffer.sort(documentPositionComparator);
refs.value = buffer;
};
onBeforeUpdate(() => {
buffer = [];
});
onMounted(flush);
onUpdated(flush);
return {
refs: readonly(refs) as DeepReadonly<ShallowRef<El[]>>,
set,
};
}
function documentPositionComparator(a: Element, b: Element): number {
if (a === b) return 0;
const position = a.compareDocumentPosition(b);
if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1;
if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1;
return 0;
}