diff --git a/client/layout/sidebar/expandable.jsx b/client/layout/sidebar/expandable.jsx index abefb6d1f10282..c1d6602cc6aadb 100644 --- a/client/layout/sidebar/expandable.jsx +++ b/client/layout/sidebar/expandable.jsx @@ -3,7 +3,7 @@ */ import classNames from 'classnames'; import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useState, useRef, useLayoutEffect } from 'react'; import { get, uniqueId } from 'lodash'; /** @@ -12,6 +12,10 @@ import { get, uniqueId } from 'lodash'; import TranslatableString from 'calypso/components/translatable/proptype'; import ExpandableSidebarHeading from './expandable-heading'; import SidebarMenu from 'calypso/layout/sidebar/menu'; +import { hasTouch } from 'calypso/lib/touch-detect'; +import config from 'calypso/config'; + +const isTouch = hasTouch(); function containsSelectedSidebarItem( children ) { let selectedItemFound = false; @@ -35,6 +39,11 @@ function containsSelectedSidebarItem( children ) { return selectedItemFound; } +const offScreen = ( submenu ) => { + const rect = submenu.getBoundingClientRect(); + return rect.y + rect.height > window.innerHeight; +}; + export const ExpandableSidebarMenu = ( { className, title, @@ -48,6 +57,14 @@ export const ExpandableSidebarMenu = ( { ...props } ) => { let { expanded } = props; + const submenu = useRef(); + const [ submenuHovered, setSubmenuHovered ] = useState( false ); + + if ( submenu.current ) { + // Sets flyout to expand towards bottom + submenu.current.style.bottom = 'auto'; + submenu.current.style.top = 0; + } if ( null === expanded ) { expanded = containsSelectedSidebarItem( children ); @@ -56,12 +73,41 @@ export const ExpandableSidebarMenu = ( { const classes = classNames( className, { 'is-toggle-open': !! expanded, 'is-togglable': true, + hovered: submenuHovered, } ); + const onEnter = () => { + if ( expanded || isTouch ) { + return; + } + + setSubmenuHovered( true ); + }; + + const onLeave = () => { + if ( expanded || isTouch ) { + return; + } + + setSubmenuHovered( false ); + }; + const menuId = uniqueId( 'menu' ); + useLayoutEffect( () => { + if ( submenuHovered && offScreen( submenu.current ) ) { + // Sets flyout to expand towards top + submenu.current.style.bottom = 0; + submenu.current.style.top = 'auto'; + } + }, [ submenuHovered ] ); + return ( - + onEnter() : null } + onMouseLeave={ config.isEnabled( 'nav-unification' ) ? () => onLeave() : null } + > - diff --git a/client/layout/sidebar/menu.jsx b/client/layout/sidebar/menu.jsx index 3a76ecc6807cba..be69d6fee12f2f 100644 --- a/client/layout/sidebar/menu.jsx +++ b/client/layout/sidebar/menu.jsx @@ -5,8 +5,10 @@ import React from 'react'; import classNames from 'classnames'; -const SidebarMenu = ( { children, className } ) => ( - +const SidebarMenu = ( { children, className, ...props } ) => ( + ); export default SidebarMenu; diff --git a/client/my-sites/sidebar-unified/menu.jsx b/client/my-sites/sidebar-unified/menu.jsx index 3e5f7bfc457dd1..171ab348e16b3d 100644 --- a/client/my-sites/sidebar-unified/menu.jsx +++ b/client/my-sites/sidebar-unified/menu.jsx @@ -60,38 +60,40 @@ export const MySitesSidebarUnifiedMenu = ( { }, [ selected, childIsSelected, reduxDispatch, sectionId, sidebarCollapsed ] ); return ( - { - if ( link ) { - if ( isExternal( link ) ) { - // If the URL is external, page() will fail to replace state between different domains - externalRedirect( link ); - return; +
  • + { + if ( link ) { + if ( isExternal( link ) ) { + // If the URL is external, page() will fail to replace state between different domains. + externalRedirect( link ); + return; + } + page( link ); } - page( link ); - } - if ( ! sidebarCollapsed ) { - reduxDispatch( collapseAllMySitesSidebarSections() ); - reduxDispatch( toggleSection( sectionId ) ); - } - } } - expanded={ ! sidebarCollapsed && isExpanded } - title={ title } - customIcon={ } - className={ ( selected || childIsSelected ) && 'sidebar__menu--selected' } - > - { children.map( ( item ) => { - const isSelected = selectedMenuItem?.url === item.url; - return ( - - ); - } ) } - + if ( ! sidebarCollapsed ) { + reduxDispatch( collapseAllMySitesSidebarSections() ); + reduxDispatch( toggleSection( sectionId ) ); + } + } } + expanded={ ! sidebarCollapsed && isExpanded } + title={ title } + customIcon={ } + className={ ( selected || childIsSelected ) && 'sidebar__menu--selected' } + > + { children.map( ( item ) => { + const isSelected = selectedMenuItem?.url === item.url; + return ( + + ); + } ) } + +
  • ); }; diff --git a/client/my-sites/sidebar-unified/style.scss b/client/my-sites/sidebar-unified/style.scss index e4945d3a94df0a..49f35dadd578ab 100644 --- a/client/my-sites/sidebar-unified/style.scss +++ b/client/my-sites/sidebar-unified/style.scss @@ -483,7 +483,8 @@ $font-size: rem( 14px ); } } - .sidebar__menu.is-togglable:not( .is-toggle-open ):hover { + .sidebar__menu.is-togglable:not( .is-toggle-open ).hovered { + // .hovered is handled in client/layout/sidebar/expandable.jsx. Needed for repositioning and hover intent. position: relative; .sidebar__heading { @@ -500,8 +501,9 @@ $font-size: rem( 14px ); // flyout content .sidebar__expandable-content { display: block; - position: absolute; top: 0; + bottom: auto; + position: absolute; left: var( --sidebar-width-max ); width: 160px; diff --git a/client/my-sites/sidebar/test/sidebar.js b/client/my-sites/sidebar/test/sidebar.js index 00782a4330839d..74880e918b464e 100644 --- a/client/my-sites/sidebar/test/sidebar.js +++ b/client/my-sites/sidebar/test/sidebar.js @@ -1,3 +1,7 @@ +/** + * @jest-environment jsdom + */ + /** * External dependencies */