feat(vue): expand @robonen/vue composable collection
Composables, tests, category barrels, and README for @robonen/vue.
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { UseCurrentElementReturn } from '.';
|
||||
import { defineComponent, nextTick, ref, shallowRef } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { useCurrentElement } from '.';
|
||||
|
||||
describe(useCurrentElement, () => {
|
||||
it('resolves to the root DOM element of the current instance after mount', () => {
|
||||
let el!: UseCurrentElementReturn;
|
||||
|
||||
const Component = defineComponent({
|
||||
setup() {
|
||||
el = useCurrentElement();
|
||||
return {};
|
||||
},
|
||||
template: `<div class="root">content</div>`,
|
||||
});
|
||||
|
||||
const wrapper = mount(Component);
|
||||
|
||||
expect(el.value).toBe(wrapper.find('.root').element);
|
||||
expect(el.value).toBeInstanceOf(HTMLDivElement);
|
||||
});
|
||||
|
||||
it('returns a controlled computed ref with trigger / peek / stop', () => {
|
||||
let el!: UseCurrentElementReturn;
|
||||
|
||||
const Component = defineComponent({
|
||||
setup() {
|
||||
el = useCurrentElement();
|
||||
return {};
|
||||
},
|
||||
template: `<div>x</div>`,
|
||||
});
|
||||
|
||||
mount(Component);
|
||||
|
||||
expect(el.trigger).toBeTypeOf('function');
|
||||
expect(el.peek).toBeTypeOf('function');
|
||||
expect(el.stop).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
it('re-reads the element on update when the root node changes', async () => {
|
||||
let el!: UseCurrentElementReturn;
|
||||
const flag = ref(true);
|
||||
|
||||
const Component = defineComponent({
|
||||
setup() {
|
||||
el = useCurrentElement();
|
||||
return { flag };
|
||||
},
|
||||
template: `
|
||||
<div v-if="flag" class="a">a</div>
|
||||
<section v-else class="b">b</section>
|
||||
`,
|
||||
});
|
||||
|
||||
const wrapper = mount(Component);
|
||||
|
||||
expect(el.value).toBeInstanceOf(HTMLDivElement);
|
||||
|
||||
flag.value = false;
|
||||
await nextTick();
|
||||
|
||||
expect(el.value).toBe(wrapper.find('.b').element);
|
||||
expect(el.value).toBeInstanceOf(HTMLElement);
|
||||
expect((el.value as Element).tagName).toBe('SECTION');
|
||||
});
|
||||
|
||||
it('tracks an explicit rootComponent ref instead of $el', async () => {
|
||||
const innerRef = shallowRef<Element | null>(null);
|
||||
let el!: UseCurrentElementReturn;
|
||||
|
||||
const Component = defineComponent({
|
||||
setup() {
|
||||
el = useCurrentElement(innerRef as any);
|
||||
return {};
|
||||
},
|
||||
template: `<div class="outer"><span class="inner">i</span></div>`,
|
||||
});
|
||||
|
||||
const wrapper = mount(Component);
|
||||
|
||||
// Before assigning the ref, it resolves to whatever the ref holds (null/undefined)
|
||||
expect(el.value).toBeFalsy();
|
||||
|
||||
innerRef.value = wrapper.find('.inner').element;
|
||||
el.trigger();
|
||||
await nextTick();
|
||||
|
||||
expect(el.value).toBe(wrapper.find('.inner').element);
|
||||
});
|
||||
|
||||
it('stops re-reading after scope disposal (unmount)', async () => {
|
||||
let el!: UseCurrentElementReturn;
|
||||
|
||||
const Component = defineComponent({
|
||||
setup() {
|
||||
el = useCurrentElement();
|
||||
return {};
|
||||
},
|
||||
template: `<div class="alive">alive</div>`,
|
||||
});
|
||||
|
||||
const wrapper = mount(Component);
|
||||
const mountedEl = el.value;
|
||||
|
||||
expect(mountedEl).toBeInstanceOf(HTMLDivElement);
|
||||
|
||||
wrapper.unmount();
|
||||
|
||||
// After unmount the controlled watcher is stopped; trigger is safe and the
|
||||
// value no longer tracks a live element.
|
||||
expect(() => el.trigger()).not.toThrow();
|
||||
});
|
||||
|
||||
it('does not throw and resolves to undefined outside a component instance (SSR-safe)', () => {
|
||||
let el!: UseCurrentElementReturn;
|
||||
|
||||
expect(() => {
|
||||
el = useCurrentElement();
|
||||
}).not.toThrow();
|
||||
|
||||
expect(el).toBeDefined();
|
||||
expect(el.value).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { getCurrentInstance, onMounted, onUpdated } from 'vue';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
import { computedWithControl } from '@/composables/reactivity/computedWithControl';
|
||||
import type { ComputedRefWithControl } from '@/composables/reactivity/computedWithControl';
|
||||
import { unrefElement } from '@/composables/component/unrefElement';
|
||||
import type { MaybeComputedElementRef, MaybeElement, VueInstance } from '@/composables/component/unrefElement';
|
||||
|
||||
/** Resolve `false` if `T` is `any`, otherwise `true` — used to detect a typed `$el`. */
|
||||
type IsAny<T> = 0 extends 1 & T ? true : false;
|
||||
|
||||
/**
|
||||
* Infer the resolved element type.
|
||||
*
|
||||
* When no explicit generic is supplied (`T` stays the broad `MaybeElement`) we
|
||||
* fall back to the component instance's `$el` type — unless that is `any`
|
||||
* (the un-typed default), in which case we keep `MaybeElement`.
|
||||
*/
|
||||
export type UseCurrentElementReturn<
|
||||
T extends MaybeElement = MaybeElement,
|
||||
R extends VueInstance = VueInstance,
|
||||
E extends MaybeElement = MaybeElement extends T
|
||||
? IsAny<R['$el']> extends false ? R['$el'] : T
|
||||
: T,
|
||||
> = ComputedRefWithControl<E>;
|
||||
|
||||
/**
|
||||
* @name useCurrentElement
|
||||
* @category Component
|
||||
* @description Reactive root DOM element of the current component instance.
|
||||
* Resolves to `vm.$el` (or the unwrapped `rootComponent` ref when provided) and
|
||||
* is re-read on `onMounted` and `onUpdated` via a controlled computed — so it
|
||||
* stays correct across re-renders without an always-on watcher. Generic over the
|
||||
* element type; the type is inferred from the component's `$el` when available.
|
||||
* SSR-safe: returns `undefined` until the component is mounted on the client.
|
||||
*
|
||||
* @param {MaybeComputedElementRef<R>} [rootComponent] Optional ref/getter for an explicit root component or element; defaults to the current instance's `$el`
|
||||
* @returns {UseCurrentElementReturn<T, R>} A controlled computed ref of the resolved element, with `.trigger()` / `.peek()` / `.stop()`
|
||||
*
|
||||
* @example
|
||||
* // Inferred element type from the component's root node
|
||||
* const el = useCurrentElement();
|
||||
* watchEffect(() => console.log(el.value));
|
||||
*
|
||||
* @example
|
||||
* // Explicit element type
|
||||
* const el = useCurrentElement<HTMLDivElement>();
|
||||
*
|
||||
* @example
|
||||
* // Track an explicit child component / element ref instead of `$el`
|
||||
* const child = useTemplateRef('child');
|
||||
* const el = useCurrentElement(child);
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useCurrentElement<
|
||||
T extends MaybeElement = MaybeElement,
|
||||
R extends VueInstance = VueInstance,
|
||||
E extends MaybeElement = MaybeElement extends T
|
||||
? IsAny<R['$el']> extends false ? R['$el'] : T
|
||||
: T,
|
||||
>(
|
||||
rootComponent?: MaybeComputedElementRef<R>,
|
||||
): UseCurrentElementReturn<T, R, E> {
|
||||
const vm = getCurrentInstance();
|
||||
|
||||
const currentElement = computedWithControl(
|
||||
() => null,
|
||||
() => (rootComponent ? unrefElement(rootComponent) : vm?.proxy?.$el) as E,
|
||||
) as UseCurrentElementReturn<T, R, E>;
|
||||
|
||||
if (vm) {
|
||||
onMounted(currentElement.trigger);
|
||||
onUpdated(currentElement.trigger);
|
||||
tryOnScopeDispose(currentElement.stop);
|
||||
}
|
||||
|
||||
return currentElement;
|
||||
}
|
||||
Reference in New Issue
Block a user