From 914a0ce6e57de68156dff908a8e789edc6b1c691 Mon Sep 17 00:00:00 2001 From: * <*> Date: Tue, 16 May 2023 20:58:14 +0800 Subject: [PATCH] Match-id-b191193225d35dcd637d7ef1902db54e05f62f6f --- libs/horizon/src/dom/DOMExternal.ts | 4 +- libs/horizon/src/event/EventBinding.ts | 13 +- libs/horizon/src/event/EventHub.ts | 5 + libs/horizon/src/event/EventWrapper.ts | 3 + libs/horizon/src/event/HorizonEventMain.ts | 23 +- libs/horizon/src/event/ListenerGetter.ts | 90 ++++++ libs/horizon/src/event/MouseEvent.ts | 109 +++++++ libs/horizon/src/event/utils.ts | 26 +- libs/horizon/src/renderer/render/DomPortal.ts | 4 +- libs/horizon/src/renderer/vnode/VNodeFlags.ts | 7 + .../EventTest/MouseEnterEvent.test.js | 301 ++++++++++++++++++ 11 files changed, 571 insertions(+), 14 deletions(-) create mode 100644 libs/horizon/src/event/MouseEvent.ts create mode 100644 scripts/__tests__/EventTest/MouseEnterEvent.test.js diff --git a/libs/horizon/src/dom/DOMExternal.ts b/libs/horizon/src/dom/DOMExternal.ts index 556f5952..8a04a66a 100644 --- a/libs/horizon/src/dom/DOMExternal.ts +++ b/libs/horizon/src/dom/DOMExternal.ts @@ -18,7 +18,8 @@ import { createPortal } from '../renderer/components/CreatePortal'; import type { Container } from './DOMOperator'; import { isElement } from './utils/Common'; import { findDOMByClassInst } from '../renderer/vnode/VNodeUtils'; -import { Callback } from '../renderer/UpdateHandler'; +import {Callback} from '../renderer/UpdateHandler'; +import {listenSimulatedDelegatedEvents} from '../event/EventBinding'; function createRoot(children: any, container: Container, callback?: Callback) { // 清空容器 @@ -31,6 +32,7 @@ function createRoot(children: any, container: Container, callback?: Callback) { // 调度器创建根节点,并给容器dom赋vNode结构体 const treeRoot = createTreeRootVNode(container); container._treeRoot = treeRoot; + listenSimulatedDelegatedEvents(treeRoot); // 执行回调 if (typeof callback === 'function') { diff --git a/libs/horizon/src/event/EventBinding.ts b/libs/horizon/src/event/EventBinding.ts index fd7a4d0c..ddfb016c 100644 --- a/libs/horizon/src/event/EventBinding.ts +++ b/libs/horizon/src/event/EventBinding.ts @@ -16,16 +16,14 @@ /** * 事件绑定实现,分为绑定委托事件和非委托事件 */ -import { allDelegatedHorizonEvents, allDelegatedNativeEvents } from './EventHub'; -import { isDocument } from '../dom/utils/Common'; +import {allDelegatedHorizonEvents, simulatedDelegatedEvents} from './EventHub'; +import {isDocument} from '../dom/utils/Common'; import { getNearestVNode, getNonDelegatedListenerMap } from '../dom/DOMInternalKeys'; import { asyncUpdates, runDiscreteUpdates } from '../renderer/TreeBuilder'; import { handleEventMain } from './HorizonEventMain'; import { decorateNativeEvent } from './EventWrapper'; import { VNode } from '../renderer/vnode/VNode'; -const listeningMarker = '_horizonListening' + Math.random().toString(36).slice(4); - // 触发委托事件 function triggerDelegatedEvent( nativeEvtName: string, @@ -64,6 +62,13 @@ function isCaptureEvent(horizonEventName) { return horizonEventName.slice(-7) === 'Capture'; } +// 利用冒泡事件模拟不冒泡事件,需要直接在根节点绑定 +export function listenSimulatedDelegatedEvents(root: VNode) { + for (let i = 0; i < simulatedDelegatedEvents.length; i++) { + lazyDelegateOnRoot(root, simulatedDelegatedEvents[i]); + } +} + // 事件懒委托,当用户定义事件后,再进行委托到根节点 export function lazyDelegateOnRoot(currentRoot: VNode, eventName: string) { currentRoot.delegatedEvents.add(eventName); diff --git a/libs/horizon/src/event/EventHub.ts b/libs/horizon/src/event/EventHub.ts index 35ad13ac..e1a80969 100644 --- a/libs/horizon/src/event/EventHub.ts +++ b/libs/horizon/src/event/EventHub.ts @@ -15,6 +15,9 @@ // 需要委托的horizon事件和原生事件对应关系 export const allDelegatedHorizonEvents = new Map(); + +// 模拟委托事件,事件本事不冒泡,需要其他事件来触发冒泡过程 +export const simulatedDelegatedEvents = ['onMouseEnter', 'onMouseLeave']; // 所有委托的原生事件集合 export const allDelegatedNativeEvents = new Set(); @@ -49,6 +52,8 @@ export const horizonEventToNativeMap = new Map([ ['onCompositionUpdate', ['compositionupdate']], ['onChange', ['change', 'click', 'focusout', 'input']], ['onSelect', ['select']], + ['onMouseEnter', ['mouseout', 'mouseover']], + ['onMouseLeave', ['mouseout', 'mouseover']], ['onAnimationEnd', ['animationend']], ['onAnimationIteration', ['animationiteration']], diff --git a/libs/horizon/src/event/EventWrapper.ts b/libs/horizon/src/event/EventWrapper.ts index 6ed15a19..98906694 100644 --- a/libs/horizon/src/event/EventWrapper.ts +++ b/libs/horizon/src/event/EventWrapper.ts @@ -37,6 +37,9 @@ export class WrappedEvent { key: string; currentTarget: EventTarget | null = null; + target: HTMLElement; + relatedTarget: HTMLElement; + stopPropagation: () => void; preventDefault: () => void; diff --git a/libs/horizon/src/event/HorizonEventMain.ts b/libs/horizon/src/event/HorizonEventMain.ts index 5c0ae747..ad0e8326 100644 --- a/libs/horizon/src/event/HorizonEventMain.ts +++ b/libs/horizon/src/event/HorizonEventMain.ts @@ -30,7 +30,8 @@ import { import { getDomTag } from '../dom/utils/Common'; import { updateInputHandlerIfChanged } from '../dom/valueHandler/ValueChangeHandler'; import { getDom } from '../dom/DOMInternalKeys'; -import { recordChangeEventTargets, shouldControlValue, tryControlValue } from './FormValueController'; +import {recordChangeEventTargets, shouldControlValue, tryControlValue} from './FormValueController'; +import {getMouseEnterListeners} from './MouseEvent'; // web规范,鼠标右键key值 const RIGHT_MOUSE_BUTTON = 2; @@ -141,18 +142,26 @@ function triggerHorizonEvents( const target = nativeEvent.target || nativeEvent.srcElement!; // 触发普通委托事件 - let listenerList: ListenerUnitList = getCommonListeners(nativeEvtName, vNode, nativeEvent, target, isCapture); + const listenerList: ListenerUnitList = getCommonListeners(nativeEvtName, vNode, nativeEvent, target, isCapture); + let mouseEnterListeners: ListenerUnitList = []; + if (horizonEventToNativeMap.get('onMouseEnter')!.includes(nativeEvtName)) { + mouseEnterListeners = getMouseEnterListeners( + nativeEvtName, + vNode, + nativeEvent, + target, + ); + } + + let changeEvents: ListenerUnitList = []; // 触发特殊handler委托事件 if (!isCapture && horizonEventToNativeMap.get('onChange')!.includes(nativeEvtName)) { - const changeListeners = getChangeListeners(nativeEvtName, nativeEvent, vNode, target); - if (changeListeners.length) { - listenerList = listenerList.concat(changeListeners); - } + changeEvents = getChangeListeners(nativeEvtName, nativeEvent, vNode, target); } // 处理触发的事件队列 - processListeners(listenerList); + processListeners([...listenerList, ...mouseEnterListeners, ...changeEvents]); } // 其他事件正在执行中标记 diff --git a/libs/horizon/src/event/ListenerGetter.ts b/libs/horizon/src/event/ListenerGetter.ts index efd363fb..cda9c3e1 100644 --- a/libs/horizon/src/event/ListenerGetter.ts +++ b/libs/horizon/src/event/ListenerGetter.ts @@ -86,3 +86,93 @@ export function getListenersFromTree( return listeners; } + +// 获取enter和leave事件队列 +export function collectMouseListeners( + leaveEvent: null | WrappedEvent, + enterEvent: null | WrappedEvent, + from: VNode | null, + to: VNode | null, +): ListenerUnitList { + // 确定公共父节点,作为在树上遍历的终点 + const commonParent = from && to ? getCommonAncestor(from, to) : null; + let leaveEventList: ListenerUnitList = []; + if (from && leaveEvent) { + // 遍历树,获取绑定的leave事件 + leaveEventList = getMouseListenersFromTree( + leaveEvent, + from, + commonParent, + ); + } + let enterEventList: ListenerUnitList = []; + if (to && enterEvent) { + // 先触发父节点enter事件,所以需要逆序 + enterEventList = getMouseListenersFromTree( + enterEvent, + to, + commonParent, + ).reverse(); + } + return [...leaveEventList, ...enterEventList]; +} + +function getMouseListenersFromTree( + event: WrappedEvent, + target: VNode, + commonParent: VNode | null, +): ListenerUnitList { + const registrationName = event.customEventName; + const listeners: ListenerUnitList = []; + + let vNode = target; + while (vNode !== null) { + // commonParent作为终点 + if (vNode === commonParent) { + break; + } + const {realNode, tag} = vNode; + if (tag === DomComponent && realNode !== null) { + const currentTarget = realNode; + const listener = getListenerFromVNode(vNode, registrationName); + if (listener) { + listeners.push({ + vNode, + listener, + currentTarget, + event, + }); + } + } + vNode = vNode.parent; + } + return listeners; +} + +// 寻找两个节点的共同最近祖先,如果没有则返回null +function getCommonAncestor(instA: VNode, instB: VNode): VNode | null { + const parentsSet = new Set(); + for (let tempA: VNode | null = instA; tempA; tempA = getParent(tempA)) { + parentsSet.add(tempA); + } + for (let tempB: VNode | null = instB; tempB; tempB = getParent(tempB)) { + if (parentsSet.has(tempB)) { + return tempB; + } + } + return null; +} + +// 获取父节点 +function getParent(inst: VNode | null): VNode | null { + if (inst === null) { + return null; + } + do { + inst = inst.parent; + } while (inst && inst.tag !== DomComponent); + if (inst) { + return inst; + } + return null; +} diff --git a/libs/horizon/src/event/MouseEvent.ts b/libs/horizon/src/event/MouseEvent.ts new file mode 100644 index 00000000..e947a98f --- /dev/null +++ b/libs/horizon/src/event/MouseEvent.ts @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2020 Huawei Technologies Co.,Ltd. + * + * openGauss is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import {getNearestVNode} from '../dom/DOMInternalKeys'; +import {WrappedEvent} from './EventWrapper'; +import {VNode} from '../renderer/vnode/VNode'; +import {AnyNativeEvent, ListenerUnitList} from './Types'; +import {DomComponent, DomText} from '../renderer/vnode/VNodeTags'; +import {collectMouseListeners} from './ListenerGetter'; +import {getNearestMountedVNode} from './utils'; + +/** + * 背景: mouseEnter和mouseLeave事件不冒泡,所以无法直接委托给根节点进行代理 + * 实现方案:利用mouseout、mouseover事件的,找到事件触发的起点和终点,判断出鼠标移动轨迹,在轨迹中的节点触发mouseEnter和mouseLeave事件 + * 步骤: + * 1. 根节点绑定mouseout和mouseover事件 + * 2. 事件触发后找到事件的起点和终点 + * 3. 封装装enter和leave事件 + * 4. 根据起止点找到公共父节点,作为事件冒泡的终点 + * 5. 遍历treeNode,找到每个节点绑定的mouseEnter和mouseLeave监听方法 + * 例如: mouseOut事件由D->C, A节点作为公共父节点,将触发 D、B的mouseLeave事件和C节点的mouseEnter事件 + * A + * / \ + * B C + * / \ + * D E + * + */ + +function getWrapperEvents(nativeEventTarget, fromInst, toInst, nativeEvent, targetInst): (WrappedEvent | null)[] { + const vWindow = nativeEventTarget.window === nativeEventTarget ? nativeEventTarget : nativeEventTarget.ownerDocument.defaultView; + + // 起点或者终点为空的话默认值为所在window + const fromNode = fromInst?.realNode || vWindow; + const toNode = toInst?.realNode || vWindow; + let leave: WrappedEvent | null = null; + let enter: WrappedEvent | null = null; + const nativeTargetInst = getNearestVNode(nativeEventTarget); + + // 在Mounted的dom节点上render一个子组件,系统中存在两个根节点,子节点的mouseout事件触发两次,取离target近的根节点生效 + if (nativeTargetInst === targetInst) { + leave = new WrappedEvent('onMouseLeave', 'mouseleave', nativeEvent); + leave.target = fromNode; + leave.relatedTarget = toNode; + + enter = new WrappedEvent('onMouseEnter', 'mouseenter', nativeEvent); + enter.target = toNode; + enter.relatedTarget = fromNode; + } + return [leave, enter]; +} + +function getEndpointVNode( + domEventName: string, + targetInst: null | VNode, + nativeEvent: AnyNativeEvent, +): (VNode | null)[] { + let fromVNode; + let toVNode; + if (domEventName === 'mouseover') { + fromVNode = null; + toVNode = targetInst; + } else { + const related = nativeEvent.relatedTarget || nativeEvent.toElement; + fromVNode = targetInst; + toVNode = related ? getNearestVNode(related) : null; + if (toVNode !== null) { + const nearestMounted = getNearestMountedVNode(toVNode); + if (toVNode !== nearestMounted || (toVNode.tag !== DomComponent && toVNode.tag !== DomText)) { + toVNode = null; + } + } + } + return [fromVNode, toVNode]; +} + +export function getMouseEnterListeners( + domEventName: string, + targetInst: null | VNode, + nativeEvent: AnyNativeEvent, + nativeEventTarget: null | EventTarget, +): ListenerUnitList { + + // 获取起点和终点的VNode + const [fromVNode, toVNode] = getEndpointVNode(domEventName, targetInst, nativeEvent); + if (fromVNode === toVNode) { + return []; + } + + // 获取包装后的leave和enter事件 + const [leave, enter] = getWrapperEvents(nativeEventTarget, fromVNode, toVNode, nativeEvent, targetInst); + + // 收集事件的监听方法 + return collectMouseListeners(leave, enter, fromVNode, toVNode); +} + + diff --git a/libs/horizon/src/event/utils.ts b/libs/horizon/src/event/utils.ts index 6f21e52e..484d98b4 100644 --- a/libs/horizon/src/event/utils.ts +++ b/libs/horizon/src/event/utils.ts @@ -13,6 +13,10 @@ * See the Mulan PSL v2 for more details. */ +import {VNode} from '../renderer/vnode/VNode'; +import {Addition, FlagUtils} from '../renderer/vnode/VNodeFlags'; +import {TreeRoot} from '../renderer/vnode/VNodeTags'; + export function isInputElement(dom?: HTMLElement): boolean { return dom instanceof HTMLInputElement || dom instanceof HTMLTextAreaElement; } @@ -20,6 +24,26 @@ export function isInputElement(dom?: HTMLElement): boolean { export function setPropertyWritable(obj, propName) { const desc = Object.getOwnPropertyDescriptor(obj, propName); if (!desc || !desc.writable) { - Object.defineProperty(obj, propName, { writable: true }); + Object.defineProperty(obj, propName, {writable: true}); } } + +// 获取离 vNode 最近的已挂载 vNode,包含它自己 +export function getNearestMountedVNode(vNode: VNode): null | VNode { + let node = vNode; + let target = vNode; + // 如果没有alternate,说明是可能是未插入的新树,需要处理插入的副作用。 + while (node.parent) { + // 存在更新,节点未挂载,查找父节点,但是父节点也可能未挂载,需要继续往上查找无更新节点 + if (FlagUtils.hasFlag(node, Addition)) { + target = node.parent; + } + node = node.parent; + } + // 如果根节点是 Dom 类型节点,表示已经挂载 + if (node.tag === TreeRoot) { + return target; + } + // 如果没有找到根节点,意味着Tree已经卸载或者未挂载 + return null; +} diff --git a/libs/horizon/src/renderer/render/DomPortal.ts b/libs/horizon/src/renderer/render/DomPortal.ts index 4ef690fe..65188f0a 100644 --- a/libs/horizon/src/renderer/render/DomPortal.ts +++ b/libs/horizon/src/renderer/render/DomPortal.ts @@ -16,10 +16,12 @@ import type { VNode } from '../Types'; import { resetNamespaceCtx, setNamespaceCtx } from '../ContextSaver'; import { createChildrenByDiff } from '../diff/nodeDiffComparator'; -import { popCurrentRoot, pushCurrentRoot } from '../RootStack'; +import {popCurrentRoot, pushCurrentRoot} from '../RootStack'; +import {listenSimulatedDelegatedEvents} from '../../event/EventBinding'; export function bubbleRender(processing: VNode) { resetNamespaceCtx(processing); + listenSimulatedDelegatedEvents(processing); popCurrentRoot(); } diff --git a/libs/horizon/src/renderer/vnode/VNodeFlags.ts b/libs/horizon/src/renderer/vnode/VNodeFlags.ts index d1971106..31e40aa7 100644 --- a/libs/horizon/src/renderer/vnode/VNodeFlags.ts +++ b/libs/horizon/src/renderer/vnode/VNodeFlags.ts @@ -40,14 +40,20 @@ export class FlagUtils { static removeFlag(node: VNode, flag: number) { node.flags &= ~flag; } + static removeLifecycleEffectFlags(node) { node.flags &= ~LifecycleEffectArr; } + static hasAnyFlag(node: VNode) { // 有标志位 return node.flags !== InitFlag; } + static hasFlag(node: VNode, flag) { + return (node.flags & flag) !== 0; + } + static setNoFlags(node: VNode) { node.flags = InitFlag; } @@ -55,6 +61,7 @@ export class FlagUtils { static markAddition(node: VNode) { node.flags |= Addition; } + static setAddition(node: VNode) { node.flags = Addition; } diff --git a/scripts/__tests__/EventTest/MouseEnterEvent.test.js b/scripts/__tests__/EventTest/MouseEnterEvent.test.js new file mode 100644 index 00000000..e0f831c8 --- /dev/null +++ b/scripts/__tests__/EventTest/MouseEnterEvent.test.js @@ -0,0 +1,301 @@ +/* + * Copyright (c) 2020 Huawei Technologies Co.,Ltd. + * + * openGauss is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import * as Horizon from '@cloudsop/horizon/index.ts'; +import * as Renderer from '../../../libs/horizon/src/renderer/Renderer'; +import { doc } from 'prettier'; + +describe('EnterLeaveEventPlugin', () => { + let container; + + beforeEach(() => { + jest.resetModules(); + // The container has to be attached for events to fire. + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + container = null; + }); + + it('should set onMouseLeave relatedTarget properly in iframe', () => { + const iframe = document.createElement('iframe'); + container.appendChild(iframe); + const iframeDocument = iframe.contentDocument; + iframeDocument.write( + '
', + ); + iframeDocument.close(); + + const leaveEvents = []; + const node = Horizon.render( +
{ + e.persist(); + leaveEvents.push(e); + }} + />, + iframeDocument.body.getElementsByTagName('div')[0], + ); + + node.dispatchEvent( + new MouseEvent('mouseout', { + bubbles: true, + cancelable: true, + relatedTarget: iframe.contentWindow, + }), + ); + + expect(leaveEvents.length).toBe(1); + expect(leaveEvents[0].target).toBe(node); + expect(leaveEvents[0].relatedTarget).toBe(iframe.contentWindow); + }); + + it('should set onMouseEnter relatedTarget properly in iframe', () => { + const iframe = document.createElement('iframe'); + container.appendChild(iframe); + const iframeDocument = iframe.contentDocument; + iframeDocument.write( + '
', + ); + iframeDocument.close(); + + const enterEvents = []; + const node = Horizon.render( +
{ + e.persist(); + enterEvents.push(e); + }} + />, + iframeDocument.body.getElementsByTagName('div')[0], + ); + + node.dispatchEvent( + new MouseEvent('mouseover', { + bubbles: true, + cancelable: true, + relatedTarget: null, + }), + ); + + expect(enterEvents.length).toBe(1); + expect(enterEvents[0].target).toBe(node); + expect(enterEvents[0].relatedTarget).toBe(iframe.contentWindow); + }); + + // Regression test for https://github.com/facebook/Horizon/issues/10906. + it('should find the common parent after updates', () => { + let parentEnterCalls = 0; + let childEnterCalls = 0; + let parent = null; + + class Parent extends Horizon.Component { + render() { + return ( +
parentEnterCalls++} + ref={node => (parent = node)}> + {this.props.showChild && ( +
childEnterCalls++}/> + )} +
+ ); + } + } + + Horizon.render(, container); + // The issue only reproduced on insertion during the first update. + Horizon.render(, container); + + // Enter from parent into the child. + parent.dispatchEvent( + new MouseEvent('mouseout', { + bubbles: true, + cancelable: true, + relatedTarget: parent.firstChild, + }), + ); + + // Entering a child should fire on the child, not on the parent. + expect(childEnterCalls).toBe(1); + expect(parentEnterCalls).toBe(0); + }); + + it('should call mouseEnter once from sibling rendered inside a rendered component', done => { + const mockFn1 = jest.fn(); + const mockFn2 = jest.fn(); + const mockFn3 = jest.fn(); + + class Parent extends Horizon.Component { + constructor(props) { + super(props); + this.parentEl = Horizon.createRef(); + } + + componentDidMount() { + Horizon.render(, this.parentEl.current); + } + + render() { + return
; + } + } + + class MouseEnterDetect extends Horizon.Component { + constructor(props) { + super(props); + this.firstEl = Horizon.createRef(); + this.siblingEl = Horizon.createRef(); + } + + componentDidMount() { + this.siblingEl.current.dispatchEvent( + new MouseEvent('mouseout', { + bubbles: true, + cancelable: true, + relatedTarget: this.firstEl.current, + }), + ); + expect(mockFn1.mock.calls.length).toBe(1); + expect(mockFn2.mock.calls.length).toBe(1); + expect(mockFn3.mock.calls.length).toBe(0); + done(); + } + + render() { + return ( + +
+
+ + ); + } + } + + Horizon.render(, container); + }); + + it('should call mouseEnter when pressing a non tracked Horizon node', done => { + const mockFn = jest.fn(); + + class Parent extends Horizon.Component { + constructor(props) { + super(props); + this.parentEl = Horizon.createRef(); + } + + componentDidMount() { + Horizon.render(, this.parentEl.current); + } + + render() { + return
; + } + } + + class MouseEnterDetect extends Horizon.Component { + constructor(props) { + super(props); + this.divRef = Horizon.createRef(); + this.siblingEl = Horizon.createRef(); + } + + componentDidMount() { + const attachedNode = document.createElement('div'); + this.divRef.current.appendChild(attachedNode); + attachedNode.dispatchEvent( + new MouseEvent('mouseout', { + bubbles: true, + cancelable: true, + relatedTarget: this.siblingEl.current, + }), + ); + expect(mockFn.mock.calls.length).toBe(1); + done(); + } + + render() { + return ( +
+
+
+ ); + } + } + + Horizon.render(, container); + }); + + it('should work with portals outside of the root that has onMouseLeave', () => { + const divRef = Horizon.createRef(); + const onMouseLeave = jest.fn(); + + function Component() { + return ( +
+ {Horizon.createPortal(
, document.body)} +
+ ); + } + + Horizon.render(, container); + + // Leave from the portal div + divRef.current.dispatchEvent( + new MouseEvent('mouseout', { + bubbles: true, + cancelable: true, + relatedTarget: document.body, + }), + ); + + expect(onMouseLeave).toHaveBeenCalledTimes(1); + }); + + it('should work with portals that have onMouseEnter outside of the root ', () => { + const divRef = Horizon.createRef(); + const otherDivRef = Horizon.createRef(); + const onMouseEnter = jest.fn(); + + function Component() { + return ( +
+ {Horizon.createPortal( +
, + document.body, + )} +
+ ); + } + + Horizon.render(, container); + + // Leave from the portal div + divRef.current.dispatchEvent( + new MouseEvent('mouseout', { + bubbles: true, + cancelable: true, + relatedTarget: otherDivRef.current, + }), + ); + + expect(onMouseEnter).toHaveBeenCalledTimes(1); + }); +}); + +