diff --git a/packages/radix-vue/src/Collapsible/CollapsibleContent.vue b/packages/radix-vue/src/Collapsible/CollapsibleContent.vue index dfa981004..eb3e3b11a 100644 --- a/packages/radix-vue/src/Collapsible/CollapsibleContent.vue +++ b/packages/radix-vue/src/Collapsible/CollapsibleContent.vue @@ -75,7 +75,11 @@ const props = defineProps(); diff --git a/packages/radix-vue/src/NavigationMenu/NavigationMenuTrigger.vue b/packages/radix-vue/src/NavigationMenu/NavigationMenuTrigger.vue index dfdac6f65..6bd8cb933 100644 --- a/packages/radix-vue/src/NavigationMenu/NavigationMenuTrigger.vue +++ b/packages/radix-vue/src/NavigationMenu/NavigationMenuTrigger.vue @@ -25,8 +25,9 @@ const itemContext = inject(NAVIGATION_MENU_ITEM_INJECTION_KEY); const { primitiveElement, currentElement: triggerElement } = usePrimitiveElement(); -const triggerId = makeTriggerId(context!.baseId, itemContext!.value); -const contentId = makeContentId(context!.baseId, itemContext!.value); +let triggerId = ref(""); +let contentId = ref(""); + const hasPointerMoveOpenedRef = ref(false); const wasClickCloseRef = ref(false); @@ -34,6 +35,8 @@ const open = computed(() => itemContext?.value === context?.modelValue.value); onMounted(() => { itemContext!.triggerRef = triggerElement; + triggerId.value = makeTriggerId(context!.baseId, itemContext!.value); + contentId.value = makeContentId(context!.baseId, itemContext!.value); }); const handlePointerEnter = () => { @@ -50,6 +53,7 @@ const handlePointerMove = (ev: PointerEvent) => { hasPointerMoveOpenedRef.value ) return; + context!.onTriggerEnter(itemContext!.value); hasPointerMoveOpenedRef.value = true; } diff --git a/packages/radix-vue/src/Presence/Presence.vue b/packages/radix-vue/src/Presence/Presence.vue index a8fd56697..ada87d5b6 100644 --- a/packages/radix-vue/src/Presence/Presence.vue +++ b/packages/radix-vue/src/Presence/Presence.vue @@ -3,36 +3,40 @@ import { ref, toRefs, useSlots, - vShow, withDirectives, type Directive, + type VNode, } from "vue"; import { usePresence } from "./usePresence"; import { syncRef } from "@vueuse/core"; interface PresenceProps { - // children: HTMLElement | ((props: { present: boolean }) => HTMLElement); present: boolean; + forceMount?: boolean; } + const props = defineProps(); -const { present } = toRefs(props); +const { present, forceMount } = toRefs(props); const slots = useSlots(); -let isLocalPresence = ref(false); +const isLocalPresence = ref(props.forceMount || props.present); +const node = ref(); +const { isPresent } = usePresence(present, node); -const vTest: Directive = { +const vPresence: Directive = { created(el) { - const { isPresent } = usePresence(present, el); syncRef(isLocalPresence, isPresent, { direction: "rtl" }); + node.value = el; }, }; const render = () => - // @ts-ignore - withDirectives(slots.default?.()?.[0], [ - [vTest], - [vShow, isLocalPresence.value], - ]); + forceMount.value || present.value || isLocalPresence.value + ? withDirectives( + slots.default?.({ present: isLocalPresence })?.[0] as VNode, + [[vPresence]] + ) + : null; defineExpose({ present: isLocalPresence, @@ -40,5 +44,5 @@ defineExpose({ diff --git a/packages/radix-vue/src/Presence/usePresence.ts b/packages/radix-vue/src/Presence/usePresence.ts index 597a0d515..b3e23c6ac 100644 --- a/packages/radix-vue/src/Presence/usePresence.ts +++ b/packages/radix-vue/src/Presence/usePresence.ts @@ -1,9 +1,11 @@ import { useStateMachine } from "@/shared"; -import { ref, watchEffect, type Ref, computed, nextTick } from "vue"; +import { ref, type Ref, computed, nextTick, watch, onUnmounted } from "vue"; -export function usePresence(present: Ref, node: HTMLElement) { +export function usePresence( + present: Ref, + node: Ref +) { const stylesRef = ref({} as any); - const prevPresentRef = ref(present.value); const prevAnimationNameRef = ref("none"); const initialState = present.value ? "mounted" : "unmounted"; @@ -21,98 +23,102 @@ export function usePresence(present: Ref, node: HTMLElement) { }, }); - // watchEffect(() => { - // const currentAnimationName = getAnimationName(node); - // prevAnimationNameRef.value = - // state.value === "mounted" ? currentAnimationName : "none"; - // }); + watch( + present, + async (currentPresent, prevPresent) => { + const hasPresentChanged = prevPresent !== currentPresent; + await nextTick(); + if (hasPresentChanged) { + const prevAnimationName = prevAnimationNameRef.value; + const currentAnimationName = getAnimationName(node.value); - watchEffect(async () => { - const styles = stylesRef.value; - const wasPresent = prevPresentRef.value; - const hasPresentChanged = wasPresent !== present.value; - await nextTick(); - if (hasPresentChanged) { - const prevAnimationName = prevAnimationNameRef.value; - const currentAnimationName = getAnimationName(node); - if (present.value) { - dispatch("MOUNT"); - } else if ( - currentAnimationName === "none" || - styles?.display === "none" - ) { - // If there is no exit animation or the element is hidden, animations won't run - // so we unmount instantly - dispatch("UNMOUNT"); - } else { - /** - * When `present` changes to `false`, we check changes to animation-name to - * determine whether an animation has started. We chose this approach (reading - * computed styles) because there is no `animationrun` event and `animationstart` - * fires after `animation-delay` has expired which would be too late. - */ - const isAnimating = prevAnimationName !== currentAnimationName; - // console.log(isAnimating, prevAnimationName, currentAnimationName); - if (wasPresent && isAnimating) { - dispatch("ANIMATION_OUT"); - } else { + if (currentPresent) { + dispatch("MOUNT"); + } else if ( + currentAnimationName === "none" || + stylesRef.value?.display === "none" + ) { + // If there is no exit animation or the element is hidden, animations won't run + // so we unmount instantly rv dispatch("UNMOUNT"); + } else { + /** + * When `present` changes to `false`, we check changes to animation-name to + * determine whether an animation has started. We chose this approach (reading + * computed styles) because there is no `animationrun` event and `animationstart` + * fires after `animation-delay` has expired which would be too late. + */ + const isAnimating = prevAnimationName !== currentAnimationName; + if (prevPresent && isAnimating) { + dispatch("ANIMATION_OUT"); + } else { + dispatch("UNMOUNT"); + } } } + }, + { immediate: true } + ); - prevPresentRef.value = present.value; + /** + * Triggering an ANIMATION_OUT during an ANIMATION_IN will fire an `animationcancel` + * event for ANIMATION_IN after we have entered `unmountSuspended` state. So, we + * make sure we only trigger ANIMATION_END for the currently active animation. + */ + const handleAnimationEnd = (event: AnimationEvent) => { + const currentAnimationName = getAnimationName(node.value); + const isCurrentAnimation = currentAnimationName.includes( + event.animationName + ); + if (event.target === node.value && isCurrentAnimation) { + // With React 18 concurrency this update is applied + // a frame after the animation ends, creating a flash of visible content. + // By manually flushing we ensure they sync within a frame, removing the flash. + // ReactDOM.flushSync(() => dispatch("ANIMATION_END")); + dispatch("ANIMATION_END"); } - }); + }; + const handleAnimationStart = (event: AnimationEvent) => { + if (event.target === node.value) { + // if animation occurred, store its name as the previous animation. + prevAnimationNameRef.value = getAnimationName(node.value); + } + }; - if (node) { - /** - * Triggering an ANIMATION_OUT during an ANIMATION_IN will fire an `animationcancel` - * event for ANIMATION_IN after we have entered `unmountSuspended` state. So, we - * make sure we only trigger ANIMATION_END for the currently active animation. - */ - const handleAnimationEnd = (event: AnimationEvent) => { - const currentAnimationName = getAnimationName(node); - const isCurrentAnimation = currentAnimationName.includes( - event.animationName - ); - if (event.target === node && isCurrentAnimation) { - // With React 18 concurrency this update is applied - // a frame after the animation ends, creating a flash of visible content. - // By manually flushing we ensure they sync within a frame, removing the flash. - // ReactDOM.flushSync(() => dispatch("ANIMATION_END")); + const watcher = watch( + node, + (newNode, oldNode) => { + if (newNode) { + stylesRef.value = getComputedStyle(newNode); + newNode.addEventListener("animationstart", handleAnimationStart); + newNode.addEventListener("animationcancel", handleAnimationEnd); + newNode.addEventListener("animationend", handleAnimationEnd); + } else { + // Transition to the unmounted state if the node is removed prematurely. + // We avoid doing so during cleanup as the node may change but still exist. dispatch("ANIMATION_END"); + + oldNode?.removeEventListener("animationstart", handleAnimationStart); + oldNode?.removeEventListener("animationcancel", handleAnimationEnd); + oldNode?.removeEventListener("animationend", handleAnimationEnd); } - }; - const handleAnimationStart = (event: AnimationEvent) => { - if (event.target === node) { - // if animation occurred, store its name as the previous animation. - prevAnimationNameRef.value = getAnimationName(node); - } - }; - node.addEventListener("animationstart", handleAnimationStart); - node.addEventListener("animationcancel", handleAnimationEnd); - node.addEventListener("animationend", handleAnimationEnd); + }, + { immediate: true } + ); - // node.removeEventListener("animationstart", handleAnimationStart); - // node.removeEventListener("animationcancel", handleAnimationEnd); - // node.removeEventListener("animationend", handleAnimationEnd); - } else { - // Transition to the unmounted state if the node is removed prematurely. - // We avoid doing so during cleanup as the node may change but still exist. - dispatch("ANIMATION_END"); - } + onUnmounted(() => { + watcher(); + }); const isPresent = computed(() => ["mounted", "unmountSuspended"].includes(state.value) ); - stylesRef.value = getComputedStyle(node); - return { isPresent, }; } -function getAnimationName(node: HTMLElement) { +function getAnimationName(node?: HTMLElement) { return node ? getComputedStyle(node).animationName || "none" : "none"; }