Skip to content

Commit

Permalink
Using Safe triangle to improve submenu navigation (#3536)
Browse files Browse the repository at this point in the history
* feat(safe triangle): implement safe triangle pattern into the mainnav subnavigation

* feat(safe triangle): define cursor positions

* feat(safe triangle): track initial safe start

* feat(safe triangle): remove event listener from rerender

* feat(safe triangle): delay safe triangle

* feat(safe triangle): remove debug code

* feat(safe triangle): improve changeset description

* feat(safe triangle): clean up

* feat(safe triangle): remove safe triangle delay

* feat(navbar): introduce animation delay

* feat(navbar): remove unused type casting

* feat(navbar): export type and reuse

* feat(navbar): distinguish function names properly

---------

Co-authored-by: Ddouglasz <[email protected]>
  • Loading branch information
ddouglasz and Ddouglasz authored Aug 26, 2024
1 parent bf19431 commit a452e7e
Show file tree
Hide file tree
Showing 5 changed files with 119 additions and 4 deletions.
5 changes: 5 additions & 0 deletions .changeset/sixty-knives-play.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@commercetools-frontend/application-shell': minor
---

Introduce the safe triangle pattern in the main navigation sub navigation to improve diagonal cursor navigation between menu items. This will help users easily navigate diagonally from the main menu item to a submenu item without the submenu closing before the cursor reaches it.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { css } from '@emotion/react';
import { css, keyframes } from '@emotion/react';
import styled from '@emotion/styled';
import { designTokens as appKitDesignTokens } from '@commercetools-frontend/application-components';
import { designTokens as uiKitDesignTokens } from '@commercetools-uikit/design-system';
Expand Down Expand Up @@ -34,6 +34,11 @@ const getContainerPositionBasedOnMenuItemPosition = (
`,
];

const fadeIn = keyframes`
from {opacity: 0;}
to { opacity: 1;}
`;

const Expander = styled.li<{ isVisible: boolean }>`
display: flex;
align-items: center;
Expand Down Expand Up @@ -140,7 +145,7 @@ const MenuList = styled.ul<
props.isSublistCollapsedAndActive ||
props.isSublistCollapsedAndActiveAndAbove) &&
css`
opacity: 1;
opacity: 0;
display: none;
text-align: left;
background-color: ${uiKitDesignTokens.colorAccent20};
Expand Down Expand Up @@ -280,6 +285,10 @@ const MenuListItem = styled.li<{
${MenuList}.sublist-collapsed__active__above,
:focus-within
${MenuList}.sublist-expanded__active {
animation-name: ${fadeIn};
animation-duration: 16ms;
animation-delay: 100ms;
animation-fill-mode: forwards;
display: flex;
flex-direction: column;
align-items: flex-start;
Expand Down Expand Up @@ -311,6 +320,18 @@ const MenuListItem = styled.li<{
}
`;

const SafeArea = styled.span`
position: absolute;
top: 0;
bottom: 0;
right: 100%;
/** Ensure the full width of the safe triangle is 100% of the scrollable menu area, less of its left padding (16px)
* which is also the starting point of the safe triangle/menu item.
*/
width: calc(100% - ${uiKitDesignTokens.spacing30});
clip-path: polygon(var(--safe-start), 100% 100%, 100% 0);
`;

export {
Expander,
ExpanderIcon,
Expand All @@ -320,4 +341,5 @@ export {
SublistItem,
TextLinkSublistWrapper,
NavlinkClickableContent,
SafeArea,
};
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ type MenuItemProps = {
children: ReactNode;
identifier?: string;
onKeyDown?: (e: React.KeyboardEvent<HTMLLIElement>) => void;
onMouseMove?: MouseEventHandler<HTMLLIElement>;
};
const MenuItem = (props: MenuItemProps) => {
return (
Expand All @@ -270,6 +271,7 @@ const MenuItem = (props: MenuItemProps) => {
isActive={props.isActive}
isRouteActive={Boolean(props.isMainMenuRouteActive)}
isCollapsed={!props.isMenuOpen}
onMouseMove={props.onMouseMove}
>
<ItemContent>{props.children}</ItemContent>
</MenuListItem>
Expand Down
67 changes: 65 additions & 2 deletions packages/application-shell/src/components/navbar/navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,14 @@ import {
MenuExpander,
NavBarLayout,
} from './menu-items';
import { SublistItem } from './menu-items.styles';
import { SublistItem, SafeArea } from './menu-items.styles';
import messages from './messages';
import NavBarSkeleton from './navbar-skeleton';
import nonNullable from './non-nullable';
import { Icon, IconWrapper, ItemIconText, Title } from './shared.styles';
import useNavbarStateManager from './use-navbar-state-manager';
import useNavbarStateManager, {
TMousePosition,
} from './use-navbar-state-manager';

type TProjectPermissions = {
permissions: TNormalizedPermissions | null;
Expand Down Expand Up @@ -88,6 +90,9 @@ type ApplicationMenuProps = {
projectKey: string;
useFullRedirectsForLinks: boolean;
onMenuItemClick?: MenuItemLinkProps['onClick'];
onMouseMove: MouseEventHandler<HTMLLIElement>;
mousePosition: TMousePosition;
pointerEvent?: string;
};

const getMenuVisibilitiesOfSubmenus = (menu: TNavbarMenu) =>
Expand All @@ -111,8 +116,60 @@ export const ApplicationMenu = (props: ApplicationMenuProps) => {
const [submenuVerticalPosition, setSubmenuVerticalPosition] = useState(0);
const [isSubmenuAboveMenuItem, setIsSubmenuAboveMenuItem] = useState(false);
const [isSubmenuFocused, setIsSubmenuFocused] = useState(false);
const [percentageX, setPercentageX] = useState(0);
const [percentageY, setPercentageY] = useState(0);
const observerRef = useRef<IntersectionObserver | null>(null);
const submenuRef = useRef<HTMLUListElement>(null);
const submenuSafeAreaRef = useRef<HTMLElement>(null);

/* Getting the width and height of the menu*/
const subRefBoundingClientRect = submenuRef.current?.getBoundingClientRect();
const { width: menuItemWidth, height: menuItemHeight } =
subRefBoundingClientRect ?? {};

// /* We want to track the left, top, width, and height of the safe area */
const submenuSafeAreaRefBoundingClientRect =
submenuSafeAreaRef.current?.getBoundingClientRect();
const safeAreaLeftPos = submenuSafeAreaRefBoundingClientRect?.left || 0;
const safeAreaTopPos = submenuSafeAreaRefBoundingClientRect?.top || 0;
const safeAreaWidth = submenuSafeAreaRefBoundingClientRect?.width || 0;
const safeAreaHeight = submenuSafeAreaRefBoundingClientRect?.height || 0;

const calculateSafeAreaStartPositon = useCallback(
(e) => {
const localX = e.clientX - safeAreaLeftPos;
const localY = e.clientY - safeAreaTopPos;

setPercentageX((localX / safeAreaWidth) * 100);
setPercentageY((localY / safeAreaHeight) * 100);
},
[safeAreaHeight, safeAreaLeftPos, safeAreaTopPos, safeAreaWidth]
);

useEffect(() => {
calculateSafeAreaStartPositon((e: MouseEventHandler) => e);
window.addEventListener('mousemove', calculateSafeAreaStartPositon);

return () => {
window.removeEventListener('mousemove', calculateSafeAreaStartPositon);
};
}, [
calculateSafeAreaStartPositon,
menuItemHeight,
menuItemWidth,
safeAreaHeight,
safeAreaLeftPos,
safeAreaTopPos,
safeAreaWidth,
]);

useLayoutEffect(() => {
submenuRef.current?.style.setProperty(
'--safe-start',
// Adding the +1 to slightly keep the cursor inside the safe area while moving
`${percentageX}% ${percentageY + 1}%`
);
}, [calculateSafeAreaStartPositon, percentageX, percentageY]);

const hasSubmenu =
Array.isArray(props.menu.submenu) && props.menu.submenu.length > 0;
Expand Down Expand Up @@ -227,6 +284,7 @@ export const ApplicationMenu = (props: ApplicationMenuProps) => {
onKeyDown={handleKeyDown}
onMouseEnter={props.handleToggleItem}
onMouseLeave={props.shouldCloseMenuFly}
onMouseMove={props.onMouseMove}
identifier={menuItemIdentifier}
>
<MenuItemLink
Expand Down Expand Up @@ -307,6 +365,7 @@ export const ApplicationMenu = (props: ApplicationMenuProps) => {
</RestrictedMenuItem>
))
: null}
<SafeArea ref={submenuSafeAreaRef} />
</MenuGroup>
</MenuItem>
</RestrictedMenuItem>
Expand All @@ -330,10 +389,12 @@ const NavBar = (props: TNavbarProps) => {
isMenuOpen,
isExpanderVisible,
activeItemIndex,
mousePosition,
handleToggleItem,
handleToggleMenu,
shouldCloseMenuFly,
allApplicationsNavbarMenuGroups,
getMousePosition,
} = useNavbarStateManager({
environment: props.environment,
project: props.project,
Expand Down Expand Up @@ -408,6 +469,8 @@ const NavBar = (props: TNavbarProps) => {
projectKey={props.projectKey}
useFullRedirectsForLinks={useFullRedirectsForLinks}
onMenuItemClick={props.onMenuItemClick}
onMouseMove={(e) => getMousePosition(e, itemIndex)}
mousePosition={mousePosition}
/>
);
})}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,22 +34,29 @@ type HookProps = {
environment: TApplicationContext<{}>['environment'];
project: TFetchProjectQuery['project'];
};
export type TMousePosition = {
clientX: number;
clientY: number;
};
type State = {
activeItemIndex?: string;
isExpanderVisible: boolean;
isMenuOpen: boolean;
mousePosition: TMousePosition;
};
type Action =
| { type: 'setActiveItemIndex'; payload: string }
| { type: 'unsetActiveItemIndex' }
| { type: 'setIsExpanderVisible' }
| { type: 'toggleIsMenuOpen' }
| { type: 'setIsMenuOpenAndMakeExpanderVisible'; payload: boolean }
| { type: 'getMousePosition'; payload: TMousePosition }
| { type: 'reset' };

const getInitialState = (isForcedMenuOpen: boolean | null): State => ({
isExpanderVisible: true,
isMenuOpen: isNil(isForcedMenuOpen) ? false : isForcedMenuOpen,
mousePosition: { clientX: 0, clientY: 0 },
});

const isForcedMenuOpenDefaultValue = false;
Expand All @@ -66,10 +73,13 @@ const reducer = (state: State, action: Action): State => {
return { ...state, isMenuOpen: !state.isMenuOpen };
case 'setIsMenuOpenAndMakeExpanderVisible':
return { ...state, isExpanderVisible: true, isMenuOpen: action.payload };
case 'getMousePosition':
return { ...state, mousePosition: action.payload };
case 'reset':
return {
isExpanderVisible: false,
isMenuOpen: false,
mousePosition: { clientX: 0, clientY: 0 },
};
default:
return state;
Expand Down Expand Up @@ -256,6 +266,18 @@ const useNavbarStateManager = (props: HookProps) => {
[state.activeItemIndex]
);

const getMousePosition = useCallback(
(e, itemIndex) => {
if (state.activeItemIndex === itemIndex) {
dispatch({
type: 'getMousePosition',
payload: { clientX: e.clientX, clientY: e.clientY },
});
}
},
[state.activeItemIndex]
);

const handleToggleMenu = useCallback(() => {
if (state.isMenuOpen && state.activeItemIndex) {
dispatch({ type: 'unsetActiveItemIndex' });
Expand Down Expand Up @@ -293,6 +315,7 @@ const useNavbarStateManager = (props: HookProps) => {
handleToggleItem,
handleToggleMenu,
shouldCloseMenuFly,
getMousePosition,
allApplicationsNavbarMenuGroups,
};
};
Expand Down

0 comments on commit a452e7e

Please sign in to comment.