mirror of
https://github.com/robonen/tools.git
synced 2026-03-20 10:54:44 +00:00
feat: update package.json exports to support new module formats and types
This commit is contained in:
28
vue/primitives/src/presence/Presence.vue
Normal file
28
vue/primitives/src/presence/Presence.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
export interface PresenceProps {
|
||||
present: boolean;
|
||||
forceMount?: boolean;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { usePresence } from './usePresence';
|
||||
|
||||
const {
|
||||
present,
|
||||
forceMount = false,
|
||||
} = defineProps<PresenceProps>();
|
||||
|
||||
defineSlots<{
|
||||
default?: (props: { present: boolean }) => any;
|
||||
}>();
|
||||
|
||||
const { isPresent, setRef } = usePresence(() => present);
|
||||
|
||||
defineExpose({ present: isPresent });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- @vue-expect-error ref is forwarded to slot -->
|
||||
<slot v-if="forceMount || present || isPresent" :ref="setRef" :present="isPresent" />
|
||||
</template>
|
||||
356
vue/primitives/src/presence/__test__/Presence.test.ts
Normal file
356
vue/primitives/src/presence/__test__/Presence.test.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
import { beforeEach, describe, it, expect, vi } from 'vitest';
|
||||
import type { Ref } from 'vue';
|
||||
import { defineComponent, h, nextTick, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { usePresence } from '../usePresence';
|
||||
import Presence from '../Presence.vue';
|
||||
import {
|
||||
getAnimationName,
|
||||
shouldSuspendUnmount,
|
||||
dispatchAnimationEvent,
|
||||
onAnimationSettle,
|
||||
} from '@robonen/platform/browsers';
|
||||
|
||||
vi.mock('@robonen/platform/browsers', () => ({
|
||||
getAnimationName: vi.fn(() => 'none'),
|
||||
shouldSuspendUnmount: vi.fn(() => false),
|
||||
dispatchAnimationEvent: vi.fn((el, name) => {
|
||||
el?.dispatchEvent(new CustomEvent(name, { bubbles: false, cancelable: false }));
|
||||
}),
|
||||
onAnimationSettle: vi.fn(() => vi.fn()),
|
||||
}));
|
||||
|
||||
const mockGetAnimationName = vi.mocked(getAnimationName);
|
||||
const mockShouldSuspend = vi.mocked(shouldSuspendUnmount);
|
||||
const mockDispatchEvent = vi.mocked(dispatchAnimationEvent);
|
||||
const mockOnSettle = vi.mocked(onAnimationSettle);
|
||||
|
||||
function mountUsePresence(initial: boolean) {
|
||||
const present = ref(initial);
|
||||
|
||||
const wrapper = mount(defineComponent({
|
||||
setup() {
|
||||
const { isPresent } = usePresence(present);
|
||||
return { isPresent };
|
||||
},
|
||||
render() {
|
||||
return h('div', this.isPresent ? 'visible' : 'hidden');
|
||||
},
|
||||
}));
|
||||
|
||||
return { wrapper, present };
|
||||
}
|
||||
|
||||
function mountPresenceWithAnimation(present: Ref<boolean>) {
|
||||
return mount(defineComponent({
|
||||
setup() {
|
||||
const { isPresent, setRef } = usePresence(present);
|
||||
return { isPresent, setRef };
|
||||
},
|
||||
render() {
|
||||
if (!this.isPresent) return h('div', 'hidden');
|
||||
|
||||
return h('div', {
|
||||
ref: (el: any) => this.setRef(el),
|
||||
}, 'visible');
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function findDispatchCall(name: string) {
|
||||
return mockDispatchEvent.mock.calls.find(([, n]) => n === name);
|
||||
}
|
||||
|
||||
describe('usePresence', () => {
|
||||
it('returns isPresent=true when present is true', () => {
|
||||
const { wrapper } = mountUsePresence(true);
|
||||
expect(wrapper.text()).toBe('visible');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('returns isPresent=false when present is false', () => {
|
||||
const { wrapper } = mountUsePresence(false);
|
||||
expect(wrapper.text()).toBe('hidden');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('transitions to unmounted immediately when no animation', async () => {
|
||||
const { wrapper, present } = mountUsePresence(true);
|
||||
expect(wrapper.text()).toBe('visible');
|
||||
|
||||
present.value = false;
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).toBe('hidden');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('transitions to mounted when present becomes true', async () => {
|
||||
const { wrapper, present } = mountUsePresence(false);
|
||||
expect(wrapper.text()).toBe('hidden');
|
||||
|
||||
present.value = true;
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).toBe('visible');
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Presence', () => {
|
||||
it('renders child when present is true', () => {
|
||||
const wrapper = mount(Presence, {
|
||||
props: { present: true },
|
||||
slots: { default: () => h('div', 'content') },
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toContain('content');
|
||||
expect(wrapper.find('div').exists()).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('does not render child when present is false', () => {
|
||||
const wrapper = mount(Presence, {
|
||||
props: { present: false },
|
||||
slots: { default: () => h('div', 'content') },
|
||||
});
|
||||
|
||||
expect(wrapper.html()).not.toContain('content');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('removes child when present becomes false (no animation)', async () => {
|
||||
const present = ref(true);
|
||||
|
||||
const wrapper = mount(defineComponent({
|
||||
setup() {
|
||||
return () => h(Presence, { present: present.value }, {
|
||||
default: () => h('span', 'hello'),
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
expect(wrapper.find('span').exists()).toBe(true);
|
||||
|
||||
present.value = false;
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('span').exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('adds child when present becomes true', async () => {
|
||||
const present = ref(false);
|
||||
|
||||
const wrapper = mount(defineComponent({
|
||||
setup() {
|
||||
return () => h(Presence, { present: present.value }, {
|
||||
default: () => h('span', 'hello'),
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
expect(wrapper.find('span').exists()).toBe(false);
|
||||
|
||||
present.value = true;
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('span').exists()).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('always renders child when forceMount is true', () => {
|
||||
const wrapper = mount(Presence, {
|
||||
props: { present: false, forceMount: true },
|
||||
slots: { default: () => h('span', 'always') },
|
||||
});
|
||||
|
||||
expect(wrapper.find('span').exists()).toBe(true);
|
||||
expect(wrapper.find('span').text()).toBe('always');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('exposes present state via scoped slot', () => {
|
||||
let slotPresent: boolean | undefined;
|
||||
|
||||
const wrapper = mount(Presence, {
|
||||
props: { present: true },
|
||||
slots: {
|
||||
default: (props: { present: boolean }) => {
|
||||
slotPresent = props.present;
|
||||
return h('div', 'content');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(slotPresent).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('exposes present=false via scoped slot when forceMount and not present', () => {
|
||||
let slotPresent: boolean | undefined;
|
||||
|
||||
const wrapper = mount(Presence, {
|
||||
props: { present: false, forceMount: true },
|
||||
slots: {
|
||||
default: (props: { present: boolean }) => {
|
||||
slotPresent = props.present;
|
||||
return h('div', 'content');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(slotPresent).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('usePresence (animation)', () => {
|
||||
beforeEach(() => {
|
||||
mockGetAnimationName.mockReturnValue('none');
|
||||
mockShouldSuspend.mockReturnValue(false);
|
||||
mockOnSettle.mockImplementation(() => vi.fn());
|
||||
mockDispatchEvent.mockClear();
|
||||
});
|
||||
|
||||
it('dispatches enter and after-enter when present becomes true (no animation)', async () => {
|
||||
const present = ref(false);
|
||||
const wrapper = mountPresenceWithAnimation(present);
|
||||
mockDispatchEvent.mockClear();
|
||||
|
||||
present.value = true;
|
||||
await nextTick();
|
||||
|
||||
expect(findDispatchCall('enter')).toBeTruthy();
|
||||
expect(findDispatchCall('after-enter')).toBeTruthy();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('dispatches leave and after-leave when no animation on leave', async () => {
|
||||
const present = ref(true);
|
||||
const wrapper = mountPresenceWithAnimation(present);
|
||||
await nextTick();
|
||||
mockDispatchEvent.mockClear();
|
||||
|
||||
present.value = false;
|
||||
await nextTick();
|
||||
|
||||
expect(findDispatchCall('leave')).toBeTruthy();
|
||||
expect(findDispatchCall('after-leave')).toBeTruthy();
|
||||
expect(wrapper.text()).toBe('hidden');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('suspends unmount when shouldSuspendUnmount returns true', async () => {
|
||||
mockShouldSuspend.mockReturnValue(true);
|
||||
|
||||
const present = ref(true);
|
||||
const wrapper = mountPresenceWithAnimation(present);
|
||||
await nextTick();
|
||||
mockDispatchEvent.mockClear();
|
||||
|
||||
present.value = false;
|
||||
await nextTick();
|
||||
|
||||
expect(findDispatchCall('leave')).toBeTruthy();
|
||||
expect(findDispatchCall('after-leave')).toBeUndefined();
|
||||
expect(wrapper.text()).toBe('visible');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('dispatches after-leave and unmounts when animation settles', async () => {
|
||||
mockShouldSuspend.mockReturnValue(true);
|
||||
|
||||
let settleCallback: (() => void) | undefined;
|
||||
mockOnSettle.mockImplementation((_el: any, callbacks: any) => {
|
||||
settleCallback = callbacks.onSettle;
|
||||
return vi.fn();
|
||||
});
|
||||
|
||||
const present = ref(true);
|
||||
const wrapper = mountPresenceWithAnimation(present);
|
||||
await nextTick();
|
||||
mockDispatchEvent.mockClear();
|
||||
|
||||
present.value = false;
|
||||
await nextTick();
|
||||
expect(wrapper.text()).toBe('visible');
|
||||
|
||||
settleCallback!();
|
||||
await nextTick();
|
||||
|
||||
expect(findDispatchCall('after-leave')).toBeTruthy();
|
||||
expect(wrapper.text()).toBe('hidden');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('tracks animation name on start via onAnimationSettle', async () => {
|
||||
let startCallback: ((name: string) => void) | undefined;
|
||||
mockOnSettle.mockImplementation((_el: any, callbacks: any) => {
|
||||
startCallback = callbacks.onStart;
|
||||
return vi.fn();
|
||||
});
|
||||
|
||||
const present = ref(true);
|
||||
const wrapper = mountPresenceWithAnimation(present);
|
||||
await nextTick();
|
||||
|
||||
expect(startCallback).toBeDefined();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('calls cleanup returned by onAnimationSettle on unmount', async () => {
|
||||
const cleanupFn = vi.fn();
|
||||
mockOnSettle.mockReturnValue(cleanupFn);
|
||||
|
||||
const present = ref(true);
|
||||
const wrapper = mountPresenceWithAnimation(present);
|
||||
await nextTick();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('setRef connects DOM element for animation tracking', async () => {
|
||||
const present = ref(true);
|
||||
const wrapper = mountPresenceWithAnimation(present);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).toBe('visible');
|
||||
expect(mockOnSettle).toHaveBeenCalled();
|
||||
expect(mockOnSettle.mock.calls[0]![0]).toBeInstanceOf(HTMLElement);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('resets isAnimating when node ref becomes undefined', async () => {
|
||||
mockShouldSuspend.mockReturnValue(true);
|
||||
|
||||
mockOnSettle.mockImplementation(() => vi.fn());
|
||||
|
||||
const present = ref(true);
|
||||
const showEl = ref(true);
|
||||
|
||||
const wrapper = mount(defineComponent({
|
||||
setup() {
|
||||
const { isPresent, setRef } = usePresence(present);
|
||||
return { isPresent, setRef, showEl };
|
||||
},
|
||||
render() {
|
||||
if (!showEl.value) {
|
||||
this.setRef(undefined);
|
||||
return h('div', 'no-el');
|
||||
}
|
||||
|
||||
return h('div', {
|
||||
ref: (el: any) => this.setRef(el),
|
||||
}, this.isPresent ? 'visible' : 'hidden');
|
||||
},
|
||||
}));
|
||||
|
||||
await nextTick();
|
||||
expect(wrapper.text()).toBe('visible');
|
||||
|
||||
showEl.value = false;
|
||||
await nextTick();
|
||||
expect(wrapper.text()).toBe('no-el');
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
5
vue/primitives/src/presence/index.ts
Normal file
5
vue/primitives/src/presence/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as Presence } from './Presence.vue';
|
||||
export { usePresence } from './usePresence';
|
||||
|
||||
export type { PresenceProps } from './Presence.vue';
|
||||
export type { UsePresenceReturn } from './usePresence';
|
||||
84
vue/primitives/src/presence/usePresence.ts
Normal file
84
vue/primitives/src/presence/usePresence.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { MaybeElement } from '@robonen/vue';
|
||||
import type { MaybeRefOrGetter, Ref } from 'vue';
|
||||
import { computed, readonly, shallowRef, toValue, watch } from 'vue';
|
||||
import { tryOnScopeDispose, unrefElement } from '@robonen/vue';
|
||||
import {
|
||||
dispatchAnimationEvent,
|
||||
getAnimationName,
|
||||
onAnimationSettle,
|
||||
shouldSuspendUnmount,
|
||||
} from '@robonen/platform/browsers';
|
||||
|
||||
export interface UsePresenceReturn {
|
||||
isPresent: Readonly<Ref<boolean>>;
|
||||
setRef: (v: unknown) => void;
|
||||
}
|
||||
|
||||
export function usePresence(
|
||||
present: MaybeRefOrGetter<boolean>,
|
||||
): UsePresenceReturn {
|
||||
const node = shallowRef<HTMLElement>();
|
||||
const isAnimating = shallowRef(false);
|
||||
let prevAnimationName = 'none';
|
||||
|
||||
const isPresent = computed(() => toValue(present) || isAnimating.value);
|
||||
|
||||
watch(isPresent, (current) => {
|
||||
prevAnimationName = current ? getAnimationName(node.value) : 'none';
|
||||
});
|
||||
|
||||
watch(() => toValue(present), (value, oldValue) => {
|
||||
if (value === oldValue) return;
|
||||
|
||||
if (value) {
|
||||
isAnimating.value = false;
|
||||
dispatchAnimationEvent(node.value, 'enter');
|
||||
|
||||
if (getAnimationName(node.value) === 'none') {
|
||||
dispatchAnimationEvent(node.value, 'after-enter');
|
||||
}
|
||||
}
|
||||
else {
|
||||
isAnimating.value = shouldSuspendUnmount(node.value, prevAnimationName);
|
||||
dispatchAnimationEvent(node.value, 'leave');
|
||||
|
||||
if (!isAnimating.value) {
|
||||
dispatchAnimationEvent(node.value, 'after-leave');
|
||||
}
|
||||
}
|
||||
}, { flush: 'sync' });
|
||||
|
||||
watch(node, (el, _oldEl, onCleanup) => {
|
||||
if (el) {
|
||||
const cleanup = onAnimationSettle(el, {
|
||||
onSettle: () => {
|
||||
const direction = toValue(present) ? 'enter' : 'leave';
|
||||
dispatchAnimationEvent(el, `after-${direction}`);
|
||||
isAnimating.value = false;
|
||||
},
|
||||
onStart: (animationName) => {
|
||||
prevAnimationName = animationName;
|
||||
},
|
||||
});
|
||||
|
||||
onCleanup(cleanup);
|
||||
}
|
||||
else {
|
||||
isAnimating.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
tryOnScopeDispose(() => {
|
||||
isAnimating.value = false;
|
||||
});
|
||||
|
||||
function setRef(v: unknown) {
|
||||
const el = unrefElement(v as MaybeElement);
|
||||
node.value = el instanceof HTMLElement ? el : undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
isPresent: readonly(isPresent),
|
||||
setRef,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user