]> git.ipfire.org Git - thirdparty/bootstrap.git/commitdiff
fix: make EventHandler better handle mouseenter/mouseleave events (#33310)
authoralpadev <2838324+alpadev@users.noreply.github.com>
Tue, 13 Apr 2021 03:25:58 +0000 (05:25 +0200)
committerGitHub <noreply@github.com>
Tue, 13 Apr 2021 03:25:58 +0000 (06:25 +0300)
* fix: make EventHandler better handle mouseenter/mouseleave events

* refactor: simplify custom events regex and move it to a variable

js/src/dom/event-handler.js
js/tests/unit/dom/event-handler.spec.js

index 26f6a1e3f286e40b7ebcf3d13eb4f7ff3be25b94..8ccb887fc3bf61061efdbb68c4e702e62f922ca2 100644 (file)
@@ -22,6 +22,7 @@ const customEvents = {
   mouseenter: 'mouseover',
   mouseleave: 'mouseout'
 }
+const customEventsRegex = /^(mouseenter|mouseleave)/i
 const nativeEvents = new Set([
   'click',
   'dblclick',
@@ -113,7 +114,7 @@ function bootstrapDelegationHandler(element, selector, fn) {
 
           if (handler.oneOff) {
             // eslint-disable-next-line unicorn/consistent-destructuring
-            EventHandler.off(element, event.type, fn)
+            EventHandler.off(element, event.type, selector, fn)
           }
 
           return fn.apply(target, [event])
@@ -144,14 +145,7 @@ function normalizeParams(originalTypeEvent, handler, delegationFn) {
   const delegation = typeof handler === 'string'
   const originalHandler = delegation ? delegationFn : handler
 
-  // allow to get the native events from namespaced events ('click.bs.button' --> 'click')
-  let typeEvent = originalTypeEvent.replace(stripNameRegex, '')
-  const custom = customEvents[typeEvent]
-
-  if (custom) {
-    typeEvent = custom
-  }
-
+  let typeEvent = getTypeEvent(originalTypeEvent)
   const isNative = nativeEvents.has(typeEvent)
 
   if (!isNative) {
@@ -171,6 +165,24 @@ function addHandler(element, originalTypeEvent, handler, delegationFn, oneOff) {
     delegationFn = null
   }
 
+  // in case of mouseenter or mouseleave wrap the handler within a function that checks for its DOM position
+  // this prevents the handler from being dispatched the same way as mouseover or mouseout does
+  if (customEventsRegex.test(originalTypeEvent)) {
+    const wrapFn = fn => {
+      return function (event) {
+        if (!event.relatedTarget || (event.relatedTarget !== event.delegateTarget && event.relatedTarget.contains(event.delegateTarget))) {
+          return fn.call(this, event)
+        }
+      }
+    }
+
+    if (delegationFn) {
+      delegationFn = wrapFn(delegationFn)
+    } else {
+      handler = wrapFn(handler)
+    }
+  }
+
   const [delegation, originalHandler, typeEvent] = normalizeParams(originalTypeEvent, handler, delegationFn)
   const events = getEvent(element)
   const handlers = events[typeEvent] || (events[typeEvent] = {})
@@ -219,6 +231,12 @@ function removeNamespacedHandlers(element, events, typeEvent, namespace) {
   })
 }
 
+function getTypeEvent(event) {
+  // allow to get the native events from namespaced events ('click.bs.button' --> 'click')
+  event = event.replace(stripNameRegex, '')
+  return customEvents[event] || event
+}
+
 const EventHandler = {
   on(element, event, handler, delegationFn) {
     addHandler(element, event, handler, delegationFn, false)
@@ -272,7 +290,7 @@ const EventHandler = {
     }
 
     const $ = getjQuery()
-    const typeEvent = event.replace(stripNameRegex, '')
+    const typeEvent = getTypeEvent(event)
     const inNamespace = event !== typeEvent
     const isNative = nativeEvents.has(typeEvent)
 
index e596a49b59b93b3fb9f8db07bafa0486b5c48b59..5fb1f01956da502be6be87a86b9a3e672bc5ff45 100644 (file)
@@ -77,10 +77,64 @@ describe('EventHandler', () => {
 
       div.click()
     })
+
+    it('should handle mouseenter/mouseleave like the native counterpart', done => {
+      fixtureEl.innerHTML = [
+        '<div class="outer">',
+        '<div class="inner">',
+        '<div class="nested">',
+        '<div class="deep"></div>',
+        '</div>',
+        '</div>',
+        '</div>'
+      ]
+
+      const outer = fixtureEl.querySelector('.outer')
+      const inner = fixtureEl.querySelector('.inner')
+      const nested = fixtureEl.querySelector('.nested')
+      const deep = fixtureEl.querySelector('.deep')
+
+      const enterSpy = jasmine.createSpy('mouseenter')
+      const leaveSpy = jasmine.createSpy('mouseleave')
+      const delegateEnterSpy = jasmine.createSpy('mouseenter')
+      const delegateLeaveSpy = jasmine.createSpy('mouseleave')
+
+      EventHandler.on(inner, 'mouseenter', enterSpy)
+      EventHandler.on(inner, 'mouseleave', leaveSpy)
+      EventHandler.on(outer, 'mouseenter', '.inner', delegateEnterSpy)
+      EventHandler.on(outer, 'mouseleave', '.inner', delegateLeaveSpy)
+
+      const moveMouse = (from, to) => {
+        from.dispatchEvent(new MouseEvent('mouseout', {
+          bubbles: true,
+          relatedTarget: to
+        }))
+
+        to.dispatchEvent(new MouseEvent('mouseover', {
+          bubbles: true,
+          relatedTarget: from
+        }))
+      }
+
+      moveMouse(outer, inner)
+      moveMouse(inner, nested)
+      moveMouse(nested, deep)
+      moveMouse(deep, nested)
+      moveMouse(nested, inner)
+      moveMouse(inner, outer)
+
+      setTimeout(() => {
+        expect(enterSpy.calls.count()).toBe(1)
+        expect(leaveSpy.calls.count()).toBe(1)
+        expect(delegateEnterSpy.calls.count()).toBe(1)
+        expect(delegateLeaveSpy.calls.count()).toBe(1)
+        done()
+      }, 20)
+    })
   })
 
   describe('one', () => {
-    it('should call listener just one', done => {
+    it('should call listener just once', done => {
       fixtureEl.innerHTML = '<div></div>'
 
       let called = 0
@@ -101,6 +155,28 @@ describe('EventHandler', () => {
         done()
       }, 20)
     })
+
+    it('should call delegated listener just once', done => {
+      fixtureEl.innerHTML = '<div></div>'
+
+      let called = 0
+      const div = fixtureEl.querySelector('div')
+      const obj = {
+        oneListener() {
+          called++
+        }
+      }
+
+      EventHandler.one(fixtureEl, 'bootstrap', 'div', obj.oneListener)
+
+      EventHandler.trigger(div, 'bootstrap')
+      EventHandler.trigger(div, 'bootstrap')
+
+      setTimeout(() => {
+        expect(called).toEqual(1)
+        done()
+      }, 20)
+    })
   })
 
   describe('off', () => {