feat(monorepo): migrate vue packages and apply oxlint refactors
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user