From 20bfc4f679bd63f71af716d4910c5c22e33180c0 Mon Sep 17 00:00:00 2001
From: Phil Hughes <me@iamphill.com>
Date: Tue, 1 Aug 2017 08:49:03 +0100
Subject: [PATCH] added mouseleave timeout with JS

---
 app/assets/javascripts/fly_out_nav.js   |  90 ++++++++++++----
 app/assets/stylesheets/new_sidebar.scss |   3 +-
 spec/javascripts/fly_out_nav_spec.js    | 133 +++++++++++++++++++++++-
 3 files changed, 206 insertions(+), 20 deletions(-)

diff --git a/app/assets/javascripts/fly_out_nav.js b/app/assets/javascripts/fly_out_nav.js
index f2151396d43..93101f123b5 100644
--- a/app/assets/javascripts/fly_out_nav.js
+++ b/app/assets/javascripts/fly_out_nav.js
@@ -1,3 +1,16 @@
+let hideTimeoutInterval = 0;
+let hideTimeout;
+let subitems;
+
+export const getHideTimeoutInterval = () => hideTimeoutInterval;
+
+export const hideAllSubItems = () => {
+  subitems.forEach((el) => {
+    el.parentNode.classList.remove('is-over');
+    el.style.display = 'none'; // eslint-disable-line no-param-reassign
+  });
+};
+
 export const calculateTop = (boundingRect, outerHeight) => {
   const windowHeight = window.innerHeight;
   const bottomOverflow = windowHeight - (boundingRect.top + outerHeight);
@@ -6,23 +19,64 @@ export const calculateTop = (boundingRect, outerHeight) => {
     boundingRect.top;
 };
 
+export const showSubLevelItems = (el) => {
+  const $subitems = el.querySelector('.sidebar-sub-level-items');
+
+  if (!$subitems) return;
+
+  hideAllSubItems();
+
+  if (el.classList.contains('is-over')) {
+    clearTimeout(hideTimeout);
+  } else {
+    $subitems.style.display = 'block';
+    el.classList.add('is-over');
+  }
+
+  const boundingRect = el.getBoundingClientRect();
+  const top = calculateTop(boundingRect, $subitems.offsetHeight);
+  const isAbove = top < boundingRect.top;
+
+  $subitems.style.transform = `translate3d(0, ${top}px, 0)`;
+
+  if (isAbove) {
+    $subitems.classList.add('is-above');
+  }
+};
+
+export const hideSubLevelItems = (el) => {
+  const $subitems = el.querySelector('.sidebar-sub-level-items');
+  const hideFn = () => {
+    el.classList.remove('is-over');
+    $subitems.style.display = 'none';
+    $subitems.classList.remove('is-above');
+
+    hideTimeoutInterval = 0;
+  };
+
+  if ($subitems && hideTimeoutInterval) {
+    hideTimeout = setTimeout(hideFn, hideTimeoutInterval);
+  } else if ($subitems) {
+    hideFn();
+  }
+};
+
+export const setMouseOutTimeout = (el) => {
+  if (el.closest('.sidebar-sub-level-items')) {
+    hideTimeoutInterval = 250;
+  } else {
+    hideTimeoutInterval = 0;
+  }
+};
+
 export default () => {
-  $('.sidebar-top-level-items > li:not(.active)').on('mouseover', (e) => {
-    const $this = e.currentTarget;
-    const $subitems = $('.sidebar-sub-level-items', $this).show();
-
-    if ($subitems.length) {
-      const boundingRect = $this.getBoundingClientRect();
-      const top = calculateTop(boundingRect, $subitems.outerHeight());
-      const isAbove = top < boundingRect.top;
-
-      $subitems.css({
-        transform: `translate3d(0, ${top}px, 0)`,
-      });
-
-      if (isAbove) {
-        $subitems.addClass('is-above');
-      }
-    }
-  }).on('mouseout', e => $('.sidebar-sub-level-items', e.currentTarget).hide().removeClass('is-above'));
+  const items = [...document.querySelectorAll('.sidebar-top-level-items > li:not(.active)')];
+  subitems = [...document.querySelectorAll('.sidebar-top-level-items > li:not(.active) .sidebar-sub-level-items')];
+
+  items.forEach((el) => {
+    el.addEventListener('mouseenter', e => showSubLevelItems(e.currentTarget));
+    el.addEventListener('mouseleave', e => hideSubLevelItems(e.currentTarget));
+  });
+
+  subitems.forEach(el => el.addEventListener('mouseleave', e => setMouseOutTimeout(e.target)));
 };
diff --git a/app/assets/stylesheets/new_sidebar.scss b/app/assets/stylesheets/new_sidebar.scss
index 05b72e9f425..72c12413aba 100644
--- a/app/assets/stylesheets/new_sidebar.scss
+++ b/app/assets/stylesheets/new_sidebar.scss
@@ -292,7 +292,8 @@ $new-sidebar-width: 220px;
     }
 
     &:not(.active):hover > a,
-    > a:hover {
+    > a:hover,
+    &.is-over > a {
       background-color: $white-light;
     }
   }
diff --git a/spec/javascripts/fly_out_nav_spec.js b/spec/javascripts/fly_out_nav_spec.js
index d3c6dafe460..0fdaa2d8663 100644
--- a/spec/javascripts/fly_out_nav_spec.js
+++ b/spec/javascripts/fly_out_nav_spec.js
@@ -1,6 +1,22 @@
-import { calculateTop } from '~/fly_out_nav';
+import {
+  calculateTop,
+  setMouseOutTimeout,
+  getHideTimeoutInterval,
+  hideSubLevelItems,
+  showSubLevelItems,
+} from '~/fly_out_nav';
 
 describe('Fly out sidebar navigation', () => {
+  let el;
+  beforeEach(() => {
+    el = document.createElement('div');
+    document.body.appendChild(el);
+  });
+
+  afterEach(() => {
+    el.remove();
+  });
+
   describe('calculateTop', () => {
     it('returns boundingRect top', () => {
       const boundingRect = {
@@ -24,4 +40,119 @@ describe('Fly out sidebar navigation', () => {
       ).toBe(window.innerHeight - 50);
     });
   });
+
+  describe('setMouseOutTimeout', () => {
+    it('sets hideTimeoutInterval to 150 when inside sub items', () => {
+      el.innerHTML = '<div class="sidebar-sub-level-items"><div class="js-test"></div></div>';
+
+      setMouseOutTimeout(el.querySelector('.js-test'));
+
+      expect(
+        getHideTimeoutInterval(),
+      ).toBe(150);
+    });
+
+    it('resets hideTimeoutInterval when not inside sub items', () => {
+      setMouseOutTimeout(el);
+
+      expect(
+        getHideTimeoutInterval(),
+      ).toBe(0);
+    });
+  });
+
+  describe('hideSubLevelItems', () => {
+    beforeEach(() => {
+      el.innerHTML = '<div class="sidebar-sub-level-items"></div>';
+    });
+
+    it('hides subitems', () => {
+      hideSubLevelItems(el);
+
+      expect(
+        el.querySelector('.sidebar-sub-level-items').style.display,
+      ).toBe('none');
+    });
+
+    it('removes is-over class', () => {
+      spyOn(el.classList, 'remove');
+
+      hideSubLevelItems(el);
+
+      expect(
+        el.classList.remove,
+      ).toHaveBeenCalledWith('is-over');
+    });
+
+    it('removes is-above class from sub-items', () => {
+      const subItems = el.querySelector('.sidebar-sub-level-items');
+
+      spyOn(subItems.classList, 'remove');
+
+      hideSubLevelItems(el);
+
+      expect(
+        subItems.classList.remove,
+      ).toHaveBeenCalledWith('is-above');
+    });
+
+    it('does nothing if el has no sub-items', () => {
+      el.innerHTML = '';
+
+      spyOn(el.classList, 'remove');
+
+      hideSubLevelItems(el);
+
+      expect(
+        el.classList.remove,
+      ).not.toHaveBeenCalledWith();
+    });
+  });
+
+  describe('showSubLevelItems', () => {
+    beforeEach(() => {
+      el.innerHTML = '<div class="sidebar-sub-level-items"></div>';
+    });
+
+    it('adds is-over class to el', () => {
+      spyOn(el.classList, 'add');
+
+      showSubLevelItems(el);
+
+      expect(
+        el.classList.add,
+      ).toHaveBeenCalledWith('is-over');
+    });
+
+    it('shows sub-items', () => {
+      showSubLevelItems(el);
+
+      expect(
+        el.querySelector('.sidebar-sub-level-items').style.display,
+      ).toBe('block');
+    });
+
+    it('sets transform of sub-items', () => {
+      showSubLevelItems(el);
+
+      expect(
+        el.querySelector('.sidebar-sub-level-items').style.transform,
+      ).toBe(`translate3d(0px, ${el.offsetTop}px, 0px)`);
+    });
+
+    it('sets is-above when element is above', () => {
+      const subItems = el.querySelector('.sidebar-sub-level-items');
+      subItems.style.height = '5000px';
+      el.style.position = 'relative';
+      el.style.top = '1000px';
+
+      spyOn(el.classList, 'add');
+
+      showSubLevelItems(el);
+
+      expect(
+        el.classList.add,
+      ).toHaveBeenCalledWith('is-above');
+    });
+  });
 });
-- 
2.30.9