From 640cbb00e30162e11f775d03175e931a4fd1ad70 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Fri, 4 Aug 2017 15:32:44 +0100 Subject: [PATCH 1/4] Add dynamic navigation tunnel to fly-out menus --- app/assets/javascripts/fly_out_nav.js | 137 ++++++++++++++++--- app/assets/stylesheets/new_sidebar.scss | 24 +--- spec/javascripts/fly_out_nav_spec.js | 167 +++++++++++++++++++----- 3 files changed, 253 insertions(+), 75 deletions(-) diff --git a/app/assets/javascripts/fly_out_nav.js b/app/assets/javascripts/fly_out_nav.js index 56744a440e7..9012f8b1a14 100644 --- a/app/assets/javascripts/fly_out_nav.js +++ b/app/assets/javascripts/fly_out_nav.js @@ -1,6 +1,19 @@ import Cookies from 'js-cookie'; import bp from './breakpoints'; +const IS_OVER_CLASS = 'is-over'; +const IS_ABOVE_CLASS = 'is-above'; +const IS_SHOWING_FLY_OUT_CLASS = 'is-showing-fly-out'; +let currentOpenMenu = null; +let menuCornerLocs; +let timeoutId; + +export const mousePos = []; + +export const setOpenMenu = (menu) => { currentOpenMenu = menu; }; + +export const slope = (a, b) => (b.y - a.y) / (b.x - a.x); + export const canShowActiveSubItems = (el) => { const isHiddenByMedia = bp.getBreakpointSize() === 'sm' || bp.getBreakpointSize() === 'md'; @@ -10,8 +23,28 @@ export const canShowActiveSubItems = (el) => { return true; }; + export const canShowSubItems = () => bp.getBreakpointSize() === 'sm' || bp.getBreakpointSize() === 'md' || bp.getBreakpointSize() === 'lg'; +export const getHideSubItemsInterval = () => { + if (!currentOpenMenu) return 0; + + const currentMousePos = mousePos[mousePos.length - 1]; + const prevMousePos = mousePos[0]; + const currentMousePosY = currentMousePos.y; + const [menuTop, menuBottom] = menuCornerLocs; + + if (currentMousePosY < menuTop.y || + currentMousePosY > menuBottom.y) return 0; + + if (slope(prevMousePos, menuBottom) < slope(currentMousePos, menuBottom) && + slope(prevMousePos, menuTop) > slope(currentMousePos, menuTop)) { + return 300; + } + + return 0; +}; + export const calculateTop = (boundingRect, outerHeight) => { const windowHeight = window.innerHeight; const bottomOverflow = windowHeight - (boundingRect.top + outerHeight); @@ -20,45 +53,111 @@ export const calculateTop = (boundingRect, outerHeight) => { boundingRect.top; }; -export const showSubLevelItems = (el) => { - const subItems = el.querySelector('.sidebar-sub-level-items'); +export const hideMenu = (el) => { + if (!el) return; - if (!subItems || !canShowSubItems() || !canShowActiveSubItems(el)) return; + const parentEl = el.parentNode; - subItems.style.display = 'block'; - el.classList.add('is-showing-fly-out'); - el.classList.add('is-over'); + el.style.display = ''; // eslint-disable-line no-param-reassign + el.style.transform = ''; // eslint-disable-line no-param-reassign + el.classList.remove(IS_ABOVE_CLASS); + parentEl.classList.remove(IS_OVER_CLASS); + parentEl.classList.remove(IS_SHOWING_FLY_OUT_CLASS); + setOpenMenu(null); +}; + +export const moveSubItemsToPosition = (el, subItems) => { const boundingRect = el.getBoundingClientRect(); const top = calculateTop(boundingRect, subItems.offsetHeight); const isAbove = top < boundingRect.top; subItems.classList.add('fly-out-list'); - subItems.style.transform = `translate3d(0, ${Math.floor(top)}px, 0)`; + subItems.style.transform = `translate3d(0, ${Math.floor(top)}px, 0)`; // eslint-disable-line no-param-reassign + + const subItemsRect = subItems.getBoundingClientRect(); + + menuCornerLocs = [ + { + x: subItemsRect.left, // left position of the sub items + y: subItemsRect.top, // top position of the sub items + }, + { + x: subItemsRect.left, // left position of the sub items + y: subItemsRect.top + subItemsRect.height, // bottom position of the sub items + }, + ]; if (isAbove) { - subItems.classList.add('is-above'); + subItems.classList.add(IS_ABOVE_CLASS); } }; -export const hideSubLevelItems = (el) => { +export const showSubLevelItems = (el) => { const subItems = el.querySelector('.sidebar-sub-level-items'); - if (!subItems || !canShowSubItems() || !canShowActiveSubItems(el)) return; + if (!canShowSubItems() || !canShowActiveSubItems(el)) return; - el.classList.remove('is-showing-fly-out'); - el.classList.remove('is-over'); - subItems.style.display = ''; - subItems.style.transform = ''; - subItems.classList.remove('is-above'); + el.classList.add(IS_OVER_CLASS); + + if (!subItems) return; + + subItems.style.display = 'block'; + el.classList.add(IS_SHOWING_FLY_OUT_CLASS); + + setOpenMenu(subItems); + moveSubItemsToPosition(el, subItems); +}; + +export const mouseEnterTopItems = (el) => { + clearTimeout(timeoutId); + + timeoutId = setTimeout(() => { + if (currentOpenMenu) hideMenu(currentOpenMenu); + + showSubLevelItems(el); + }, getHideSubItemsInterval()); +}; + +export const mouseLeaveTopItem = (el) => { + const subItems = el.querySelector('.sidebar-sub-level-items'); + + if (!canShowSubItems() || !canShowActiveSubItems(el) || (subItems && subItems === currentOpenMenu)) return; + + el.classList.remove(IS_OVER_CLASS); +}; + +export const documentMouseMove = (e) => { + mousePos.push({ x: e.clientX, y: e.clientY }); + + if (mousePos.length > 6) mousePos.shift(); }; export default () => { - const items = [...document.querySelectorAll('.sidebar-top-level-items > li')] - .filter(el => el.querySelector('.sidebar-sub-level-items')); + const sidebar = document.querySelector('.sidebar-top-level-items'); + const items = [...sidebar.querySelectorAll('.sidebar-top-level-items > li')]; + + sidebar.addEventListener('mouseleave', () => { + clearTimeout(timeoutId); + + timeoutId = setTimeout(() => { + if (currentOpenMenu) hideMenu(currentOpenMenu); + }, getHideSubItemsInterval()); + }); items.forEach((el) => { - el.addEventListener('mouseenter', e => showSubLevelItems(e.currentTarget)); - el.addEventListener('mouseleave', e => hideSubLevelItems(e.currentTarget)); + const subItems = el.querySelector('.sidebar-sub-level-items'); + + if (subItems) { + subItems.addEventListener('mouseleave', () => { + clearTimeout(timeoutId); + hideMenu(currentOpenMenu); + }); + } + + el.addEventListener('mouseenter', e => mouseEnterTopItems(e.currentTarget)); + el.addEventListener('mouseleave', e => mouseLeaveTopItem(e.currentTarget)); }); + + document.addEventListener('mousemove', documentMouseMove); }; diff --git a/app/assets/stylesheets/new_sidebar.scss b/app/assets/stylesheets/new_sidebar.scss index d49f23b4f5a..faedd207e01 100644 --- a/app/assets/stylesheets/new_sidebar.scss +++ b/app/assets/stylesheets/new_sidebar.scss @@ -250,32 +250,13 @@ $new-sidebar-collapsed-width: 50px; position: absolute; top: -30px; bottom: -30px; - left: 0; + left: -10px; right: -30px; z-index: -1; } - &::after { - content: ""; - position: absolute; - top: 44px; - left: -30px; - right: 35px; - bottom: 0; - height: 100%; - max-height: 150px; - z-index: -1; - transform: skew(33deg); - } - &.is-above { margin-top: 1px; - - &::after { - top: auto; - bottom: 44px; - transform: skew(-30deg); - } } > .active { @@ -322,8 +303,7 @@ $new-sidebar-collapsed-width: 50px; } } - &:not(.active):hover > a, - > a:hover, + &.active > 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 e44d874ad2b..dca58cb2eb2 100644 --- a/spec/javascripts/fly_out_nav_spec.js +++ b/spec/javascripts/fly_out_nav_spec.js @@ -1,14 +1,19 @@ import Cookies from 'js-cookie'; import { calculateTop, - hideSubLevelItems, showSubLevelItems, canShowSubItems, canShowActiveSubItems, + mouseEnterTopItems, + mouseLeaveTopItem, + setOpenMenu, + mousePos, + getHideSubItemsInterval, + documentMouseMove, } from '~/fly_out_nav'; import bp from '~/breakpoints'; -describe('Fly out sidebar navigation', () => { +fdescribe('Fly out sidebar navigation', () => { let el; let breakpointSize = 'lg'; @@ -18,11 +23,14 @@ describe('Fly out sidebar navigation', () => { document.body.appendChild(el); spyOn(bp, 'getBreakpointSize').and.callFake(() => breakpointSize); + + setOpenMenu(null); }); afterEach(() => { el.remove(); breakpointSize = 'lg'; + mousePos.length = 0; }); describe('calculateTop', () => { @@ -49,61 +57,152 @@ describe('Fly out sidebar navigation', () => { }); }); - describe('hideSubLevelItems', () => { + describe('getHideSubItemsInterval', () => { beforeEach(() => { - el.innerHTML = ''; + el.innerHTML = ''; }); - it('hides subitems', () => { - hideSubLevelItems(el); + it('returns 0 if currentOpenMenu is nil', () => { + expect( + getHideSubItemsInterval(), + ).toBe(0); + }); + + it('returns 0 when mouse above sub-items', () => { + showSubLevelItems(el); + documentMouseMove({ + clientX: el.getBoundingClientRect().left, + clientY: el.getBoundingClientRect().top, + }); + documentMouseMove({ + clientX: el.getBoundingClientRect().left, + clientY: el.getBoundingClientRect().top - 50, + }); expect( - el.querySelector('.sidebar-sub-level-items').style.display, - ).toBe(''); + getHideSubItemsInterval(), + ).toBe(0); }); - it('does not hude subitems on mobile', () => { - breakpointSize = 'xs'; + it('returns 0 when mouse is below sub-items', () => { + const subItems = el.querySelector('.sidebar-sub-level-items'); - hideSubLevelItems(el); + showSubLevelItems(el); + documentMouseMove({ + clientX: el.getBoundingClientRect().left, + clientY: el.getBoundingClientRect().top, + }); + documentMouseMove({ + clientX: el.getBoundingClientRect().left, + clientY: (el.getBoundingClientRect().top - subItems.getBoundingClientRect().height) + 50, + }); expect( - el.querySelector('.sidebar-sub-level-items').style.display, - ).not.toBe('none'); + getHideSubItemsInterval(), + ).toBe(0); }); - it('removes is-over class', () => { + it('returns 300 when mouse is moved towards sub-items', () => { + documentMouseMove({ + clientX: el.getBoundingClientRect().left, + clientY: el.getBoundingClientRect().top, + }); + showSubLevelItems(el); + documentMouseMove({ + clientX: el.getBoundingClientRect().left + 20, + clientY: el.getBoundingClientRect().top + 10, + }); + + expect( + getHideSubItemsInterval(), + ).toBe(300); + }); + }); + + describe('mouseLeaveTopItem', () => { + beforeEach(() => { spyOn(el.classList, 'remove'); + }); - hideSubLevelItems(el); + it('removes is-over class if currentOpenMenu is null', () => { + mouseLeaveTopItem(el); expect( el.classList.remove, ).toHaveBeenCalledWith('is-over'); }); - it('removes is-above class from sub-items', () => { - const subItems = el.querySelector('.sidebar-sub-level-items'); + it('removes is-over class if currentOpenMenu is null & there are sub-items', () => { + el.innerHTML = ''; - 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); + mouseLeaveTopItem(el); expect( el.classList.remove, - ).not.toHaveBeenCalledWith(); + ).toHaveBeenCalledWith('is-over'); + }); + + it('does not remove is-over class if currentOpenMenu is the passed in sub-items', () => { + el.innerHTML = ''; + + setOpenMenu(el.querySelector('.sidebar-sub-level-items')); + mouseLeaveTopItem(el); + + expect( + el.classList.remove, + ).not.toHaveBeenCalled(); + }); + }); + + describe('mouseEnterTopItems', () => { + beforeEach(() => { + jasmine.clock().install(); + + el.innerHTML = ''; + }); + + afterEach(() => { + jasmine.clock().uninstall(); + }); + + it('shows sub-items after 0ms if no menu is open', () => { + mouseEnterTopItems(el); + + expect( + getHideSubItemsInterval(), + ).toBe(0); + + jasmine.clock().tick(0); + + expect( + el.querySelector('.sidebar-sub-level-items').style.display, + ).toBe('block'); + }); + + it('shows sub-items after 300ms if a menu is currently open', () => { + documentMouseMove({ + clientX: el.getBoundingClientRect().left, + clientY: el.getBoundingClientRect().top, + }); + + setOpenMenu(el.querySelector('.sidebar-sub-level-items')); + + documentMouseMove({ + clientX: el.getBoundingClientRect().left + 20, + clientY: el.getBoundingClientRect().top + 10, + }); + + mouseEnterTopItems(el); + + expect( + getHideSubItemsInterval(), + ).toBe(300); + + jasmine.clock().tick(300); + + expect( + el.querySelector('.sidebar-sub-level-items').style.display, + ).toBe('block'); }); }); @@ -132,7 +231,7 @@ describe('Fly out sidebar navigation', () => { ).not.toBe('block'); }); - it('does not shows sub-items', () => { + it('shows sub-items', () => { showSubLevelItems(el); expect( From 80c788bbeb875e724230a6ce64f09f98bbfe6f40 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 8 Aug 2017 11:25:36 +0100 Subject: [PATCH 2/4] fixed JS error when no sidebar exists --- app/assets/javascripts/fly_out_nav.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/assets/javascripts/fly_out_nav.js b/app/assets/javascripts/fly_out_nav.js index 9012f8b1a14..9a2ab7f82b1 100644 --- a/app/assets/javascripts/fly_out_nav.js +++ b/app/assets/javascripts/fly_out_nav.js @@ -135,6 +135,9 @@ export const documentMouseMove = (e) => { export default () => { const sidebar = document.querySelector('.sidebar-top-level-items'); + + if (!sidebar) return; + const items = [...sidebar.querySelectorAll('.sidebar-top-level-items > li')]; sidebar.addEventListener('mouseleave', () => { From 449a84fe4063408b432d7bd59a399747a570fff1 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 10 Aug 2017 13:24:03 +0100 Subject: [PATCH 3/4] fixed up specs caused by left over elements in the body --- app/assets/javascripts/fly_out_nav.js | 3 ++- spec/javascripts/fly_out_nav_spec.js | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/fly_out_nav.js b/app/assets/javascripts/fly_out_nav.js index 9a2ab7f82b1..7d128cbf5fb 100644 --- a/app/assets/javascripts/fly_out_nav.js +++ b/app/assets/javascripts/fly_out_nav.js @@ -122,7 +122,8 @@ export const mouseEnterTopItems = (el) => { export const mouseLeaveTopItem = (el) => { const subItems = el.querySelector('.sidebar-sub-level-items'); - if (!canShowSubItems() || !canShowActiveSubItems(el) || (subItems && subItems === currentOpenMenu)) return; + if (!canShowSubItems() || !canShowActiveSubItems(el) || + (subItems && subItems === currentOpenMenu)) return; el.classList.remove(IS_OVER_CLASS); }; diff --git a/spec/javascripts/fly_out_nav_spec.js b/spec/javascripts/fly_out_nav_spec.js index dca58cb2eb2..65a7459c5ed 100644 --- a/spec/javascripts/fly_out_nav_spec.js +++ b/spec/javascripts/fly_out_nav_spec.js @@ -13,7 +13,7 @@ import { } from '~/fly_out_nav'; import bp from '~/breakpoints'; -fdescribe('Fly out sidebar navigation', () => { +describe('Fly out sidebar navigation', () => { let el; let breakpointSize = 'lg'; @@ -28,7 +28,7 @@ fdescribe('Fly out sidebar navigation', () => { }); afterEach(() => { - el.remove(); + document.body.innerHTML = ''; breakpointSize = 'lg'; mousePos.length = 0; }); @@ -59,7 +59,7 @@ fdescribe('Fly out sidebar navigation', () => { describe('getHideSubItemsInterval', () => { beforeEach(() => { - el.innerHTML = ''; + el.innerHTML = ''; }); it('returns 0 if currentOpenMenu is nil', () => { From 56d114921caef8588891fa2090b00823ddc4ce9f Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Mon, 14 Aug 2017 08:58:22 +0100 Subject: [PATCH 4/4] moved timeout to a variable --- app/assets/javascripts/fly_out_nav.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/fly_out_nav.js b/app/assets/javascripts/fly_out_nav.js index 7d128cbf5fb..cbc3ad23990 100644 --- a/app/assets/javascripts/fly_out_nav.js +++ b/app/assets/javascripts/fly_out_nav.js @@ -1,6 +1,7 @@ import Cookies from 'js-cookie'; import bp from './breakpoints'; +const HIDE_INTERVAL_TIMEOUT = 300; const IS_OVER_CLASS = 'is-over'; const IS_ABOVE_CLASS = 'is-above'; const IS_SHOWING_FLY_OUT_CLASS = 'is-showing-fly-out'; @@ -10,7 +11,7 @@ let timeoutId; export const mousePos = []; -export const setOpenMenu = (menu) => { currentOpenMenu = menu; }; +export const setOpenMenu = (menu = null) => { currentOpenMenu = menu; }; export const slope = (a, b) => (b.y - a.y) / (b.x - a.x); @@ -39,7 +40,7 @@ export const getHideSubItemsInterval = () => { if (slope(prevMousePos, menuBottom) < slope(currentMousePos, menuBottom) && slope(prevMousePos, menuTop) > slope(currentMousePos, menuTop)) { - return 300; + return HIDE_INTERVAL_TIMEOUT; } return 0; @@ -64,7 +65,7 @@ export const hideMenu = (el) => { parentEl.classList.remove(IS_OVER_CLASS); parentEl.classList.remove(IS_SHOWING_FLY_OUT_CLASS); - setOpenMenu(null); + setOpenMenu(); }; export const moveSubItemsToPosition = (el, subItems) => { @@ -129,7 +130,10 @@ export const mouseLeaveTopItem = (el) => { }; export const documentMouseMove = (e) => { - mousePos.push({ x: e.clientX, y: e.clientY }); + mousePos.push({ + x: e.clientX, + y: e.clientY, + }); if (mousePos.length > 6) mousePos.shift(); };