Skip to content

Commit 337028b

Browse files
authored
feat(nav): Improved hover preview behavior (#97689)
This is meant to address user concerns that the nav preview delay is too long. It should also hopefully address earlier concerns that it was too easy to accidentally activate when browsing the secondary nav. The old behavior was a 300ms delay on previewing primary nav items. The comment on `useActiveNavGroupOnHover` describes the new behavior: ```tsx /** * Hovering over a primary nav item will change the contents of the sidebar. * This hook returns event handlers which can be applied to a nav item. * * When a nav item detects a mouse enter event, it will either activate the group * immediately, or do so after a short delay depending on mouse position and angle of movement. * * There are two cases where we add a delay: * * 1. If it looks like the user is moving their mouse towards the secondary sidebar content, * an extra delay is added to prevent other nav groups from being activated. * 2. If it looks like the user is skimming the side of the nav (e.g. they are browsing the secondary * nav), an extra delay is added to prevent accidental activation. */ ```
1 parent 0c76cab commit 337028b

File tree

7 files changed

+316
-58
lines changed

7 files changed

+316
-58
lines changed

static/app/views/nav/constants.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,4 @@ export const SECONDARY_SIDEBAR_MAX_WIDTH = 450;
99

1010
export const NAV_SIDEBAR_COLLAPSE_DELAY_MS = 200;
1111
export const NAV_SIDEBAR_OPEN_DELAY_MS = 250;
12-
export const NAV_SIDEBAR_PREVIEW_DELAY_MS = 300;
1312
export const NAV_SIDEBAR_RESET_DELAY_MS = 300;

static/app/views/nav/primary/components.tsx

Lines changed: 6 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
import {Fragment, useRef, type MouseEventHandler} from 'react';
1+
import {Fragment, type MouseEventHandler} from 'react';
22
import type {Theme} from '@emotion/react';
33
import {css, useTheme} from '@emotion/react';
44
import styled from '@emotion/styled';
5-
import {useHover} from '@react-aria/interactions';
6-
import {mergeProps} from '@react-aria/utils';
75

86
import type {ButtonProps} from 'sentry/components/core/button';
97
import {Button} from 'sentry/components/core/button';
@@ -23,7 +21,6 @@ import {useLocation} from 'sentry/utils/useLocation';
2321
import useOrganization from 'sentry/utils/useOrganization';
2422
import {
2523
NAV_PRIMARY_LINK_DATA_ATTRIBUTE,
26-
NAV_SIDEBAR_PREVIEW_DELAY_MS,
2724
PRIMARY_SIDEBAR_WIDTH,
2825
} from 'sentry/views/nav/constants';
2926
import {useNavContext} from 'sentry/views/nav/context';
@@ -38,7 +35,6 @@ interface SidebarItemLinkProps {
3835
to: string;
3936
activeTo?: string;
4037
children?: React.ReactNode;
41-
onClick?: MouseEventHandler<HTMLButtonElement>;
4238
}
4339

4440
interface SidebarItemDropdownProps {
@@ -167,43 +163,6 @@ export function SidebarMenu({
167163
);
168164
}
169165

170-
function useActivateNavGroupOnHover(group: PrimaryNavGroup) {
171-
const {setActivePrimaryNavGroup, isCollapsed, collapsedNavIsOpen} = useNavContext();
172-
173-
// Slightly delay changing the active nav group to prevent accidentally triggering a new menu
174-
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
175-
const {hoverProps} = useHover({
176-
onHoverStart: () => {
177-
if (timeoutRef.current) {
178-
clearTimeout(timeoutRef.current);
179-
}
180-
181-
if (isCollapsed && !collapsedNavIsOpen) {
182-
setActivePrimaryNavGroup(group);
183-
return;
184-
}
185-
186-
timeoutRef.current = setTimeout(() => {
187-
setActivePrimaryNavGroup(group);
188-
}, NAV_SIDEBAR_PREVIEW_DELAY_MS);
189-
},
190-
onHoverEnd: () => {
191-
if (timeoutRef.current) {
192-
clearTimeout(timeoutRef.current);
193-
}
194-
},
195-
});
196-
197-
return mergeProps(hoverProps, {
198-
onClick: () => {
199-
setActivePrimaryNavGroup(group);
200-
if (timeoutRef.current) {
201-
clearTimeout(timeoutRef.current);
202-
}
203-
},
204-
});
205-
}
206-
207166
function SidebarNavLink({
208167
children,
209168
to,
@@ -217,12 +176,6 @@ function SidebarNavLink({
217176
const location = useLocation();
218177
const isActive = isLinkActive(normalizeUrl(activeTo, location), location.pathname);
219178
const label = PRIMARY_NAV_GROUP_CONFIG[group].label;
220-
const hoverProps = useActivateNavGroupOnHover(group);
221-
const linkProps = mergeProps(hoverProps, {
222-
onClick: () => {
223-
recordPrimaryItemClick(analyticsKey, organization);
224-
},
225-
});
226179

227180
return (
228181
<NavLink
@@ -231,10 +184,12 @@ function SidebarNavLink({
231184
aria-selected={activePrimaryNavGroup === group ? true : isActive}
232185
aria-current={isActive ? 'page' : undefined}
233186
isMobile={layout === NavLayout.MOBILE}
187+
onClick={() => {
188+
recordPrimaryItemClick(analyticsKey, organization);
189+
}}
234190
{...{
235191
[NAV_PRIMARY_LINK_DATA_ATTRIBUTE]: true,
236192
}}
237-
{...linkProps}
238193
>
239194
{layout === NavLayout.MOBILE ? (
240195
<Fragment>
@@ -258,11 +213,12 @@ export function SidebarLink({
258213
activeTo = to,
259214
analyticsKey,
260215
group,
216+
...props
261217
}: SidebarItemLinkProps) {
262218
const label = PRIMARY_NAV_GROUP_CONFIG[group].label;
263219

264220
return (
265-
<SidebarItem label={label} showLabel>
221+
<SidebarItem label={label} showLabel {...props}>
266222
<SidebarNavLink
267223
to={to}
268224
activeTo={activeTo}

static/app/views/nav/primary/index.tsx

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {Fragment} from 'react';
1+
import {Fragment, useRef} from 'react';
22

33
import Feature from 'sentry/components/acl/feature';
44
import ErrorBoundary from 'sentry/components/errorBoundary';
@@ -24,16 +24,27 @@ import {
2424
import {PrimaryNavigationHelp} from 'sentry/views/nav/primary/help';
2525
import {PrimaryNavigationOnboarding} from 'sentry/views/nav/primary/onboarding';
2626
import {PrimaryNavigationServiceIncidents} from 'sentry/views/nav/primary/serviceIncidents';
27+
import {useActivateNavGroupOnHover} from 'sentry/views/nav/primary/useActivateNavGroupOnHover';
2728
import {PrimaryNavigationWhatsNew} from 'sentry/views/nav/primary/whatsNew';
2829
import {NavTourElement, StackedNavigationTour} from 'sentry/views/nav/tour/tour';
2930
import {NavLayout, PrimaryNavGroup} from 'sentry/views/nav/types';
3031
import {UserDropdown} from 'sentry/views/nav/userDropdown';
3132
import {PREVENT_AI_BASE_URL, PREVENT_BASE_URL} from 'sentry/views/prevent/settings';
3233

33-
function SidebarBody({children}: {children: React.ReactNode}) {
34+
function SidebarBody({
35+
children,
36+
ref,
37+
}: {
38+
children: React.ReactNode;
39+
ref: React.RefObject<HTMLUListElement | null>;
40+
}) {
3441
const {layout} = useNavContext();
3542
return (
36-
<SidebarList isMobile={layout === NavLayout.MOBILE} data-primary-list-container>
43+
<SidebarList
44+
isMobile={layout === NavLayout.MOBILE}
45+
data-primary-list-container
46+
ref={ref}
47+
>
3748
{children}
3849
</SidebarList>
3950
);
@@ -56,15 +67,19 @@ function SidebarFooter({children}: {children: React.ReactNode}) {
5667
export function PrimaryNavigationItems() {
5768
const organization = useOrganization();
5869
const prefix = `organizations/${organization.slug}`;
70+
const ref = useRef<HTMLUListElement>(null);
71+
72+
const makeNavItemProps = useActivateNavGroupOnHover({ref});
5973

6074
return (
6175
<Fragment>
62-
<SidebarBody>
76+
<SidebarBody ref={ref}>
6377
<NavTourElement id={StackedNavigationTour.ISSUES} title={null} description={null}>
6478
<SidebarLink
6579
to={`/${prefix}/issues/`}
6680
analyticsKey="issues"
6781
group={PrimaryNavGroup.ISSUES}
82+
{...makeNavItemProps(PrimaryNavGroup.ISSUES)}
6883
>
6984
<IconIssues />
7085
</SidebarLink>
@@ -80,6 +95,7 @@ export function PrimaryNavigationItems() {
8095
activeTo={`/${prefix}/explore`}
8196
analyticsKey="explore"
8297
group={PrimaryNavGroup.EXPLORE}
98+
{...makeNavItemProps(PrimaryNavGroup.EXPLORE)}
8399
>
84100
<IconSearch />
85101
</SidebarLink>
@@ -100,6 +116,7 @@ export function PrimaryNavigationItems() {
100116
activeTo={`/${prefix}/dashboard`}
101117
analyticsKey="dashboards"
102118
group={PrimaryNavGroup.DASHBOARDS}
119+
{...makeNavItemProps(PrimaryNavGroup.DASHBOARDS)}
103120
>
104121
<IconDashboard />
105122
</SidebarLink>
@@ -117,6 +134,7 @@ export function PrimaryNavigationItems() {
117134
activeTo={`/${prefix}/insights`}
118135
analyticsKey="insights"
119136
group={PrimaryNavGroup.INSIGHTS}
137+
{...makeNavItemProps(PrimaryNavGroup.INSIGHTS)}
120138
>
121139
<IconGraph type="area" />
122140
</SidebarLink>
@@ -129,6 +147,7 @@ export function PrimaryNavigationItems() {
129147
activeTo={`/${prefix}/${PREVENT_BASE_URL}/`}
130148
analyticsKey="prevent"
131149
group={PrimaryNavGroup.PREVENT}
150+
{...makeNavItemProps(PrimaryNavGroup.PREVENT)}
132151
>
133152
<IconPrevent />
134153
</SidebarLink>
@@ -146,6 +165,7 @@ export function PrimaryNavigationItems() {
146165
activeTo={`/settings/`}
147166
analyticsKey="settings"
148167
group={PrimaryNavGroup.SETTINGS}
168+
{...makeNavItemProps(PrimaryNavGroup.SETTINGS)}
149169
>
150170
<IconSettings />
151171
</SidebarLink>
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import {useRef} from 'react';
2+
3+
import {PRIMARY_SIDEBAR_WIDTH} from 'sentry/views/nav/constants';
4+
import {useNavContext} from 'sentry/views/nav/context';
5+
import {useMouseMovement} from 'sentry/views/nav/primary/useMouseMovement';
6+
import {useWindowHeight} from 'sentry/views/nav/primary/useWindowHeight';
7+
import {NavLayout, PrimaryNavGroup} from 'sentry/views/nav/types';
8+
9+
/**
10+
* Hovering over a primary nav item will change the contents of the sidebar.
11+
* This hook returns event handlers which can be applied to a nav item.
12+
*
13+
* When a nav item detects a mouse enter event, it will either activate the group
14+
* immediately, or do so after a short delay depending on mouse position and angle of movement.
15+
*
16+
* There are two cases where we add a delay:
17+
*
18+
* 1. If it looks like the user is moving their mouse towards the secondary sidebar content,
19+
* an extra delay is added to prevent other nav groups from being activated.
20+
* 2. If it looks like the user is skimming the side of the nav (e.g. they are browsing the secondary
21+
* nav), an extra delay is added to prevent accidental activation.
22+
*/
23+
export function useActivateNavGroupOnHover({
24+
ref,
25+
}: {
26+
ref: React.RefObject<HTMLElement | null>;
27+
}) {
28+
const {layout} = useNavContext();
29+
const mouseAccelerationRef = useMouseMovement({
30+
ref,
31+
disabled: layout !== NavLayout.SIDEBAR,
32+
});
33+
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
34+
const {setActivePrimaryNavGroup, isCollapsed, collapsedNavIsOpen} = useNavContext();
35+
const windowHeight = useWindowHeight();
36+
37+
return function makeNavItemProps(group: PrimaryNavGroup) {
38+
const onMouseEnter = (e: MouseEvent) => {
39+
if (timeoutRef.current) {
40+
clearTimeout(timeoutRef.current);
41+
}
42+
43+
if (isCollapsed && !collapsedNavIsOpen) {
44+
setActivePrimaryNavGroup(group);
45+
return;
46+
}
47+
48+
const getDelay = () => {
49+
const {horizontalSpeed, verticalSpeed, horizontalDirection, verticalDirection} =
50+
mouseAccelerationRef.current;
51+
52+
const mouseX = e.clientX;
53+
const mouseY = e.clientY;
54+
55+
const distanceToRightEdge = PRIMARY_SIDEBAR_WIDTH - mouseX;
56+
const distanceToTop = mouseY;
57+
const distanceToBottom = windowHeight - mouseY;
58+
59+
// Find angle from mouse to top and bottom of nav
60+
// This is the angle of motion that likely inidcates that the user is
61+
// moving their mouse into the secondary nav.
62+
// Similar to https://bjk5.com/post/44698559168/breaking-down-amazons-mega-dropdown
63+
const angleToTop =
64+
Math.atan2(distanceToTop, distanceToRightEdge) * (180 / Math.PI);
65+
const angleToBottom =
66+
Math.atan2(distanceToBottom, distanceToRightEdge) * (180 / Math.PI);
67+
68+
const mouseDirectionAngle =
69+
horizontalSpeed === 0
70+
? 90
71+
: Math.atan2(verticalSpeed, horizontalSpeed) * (180 / Math.PI);
72+
73+
const isMovingTowardSecondaryNav =
74+
horizontalDirection > 0
75+
? verticalDirection > 0
76+
? mouseDirectionAngle < angleToBottom
77+
: mouseDirectionAngle < angleToTop
78+
: false;
79+
80+
const isSkimmingRightSide =
81+
horizontalDirection < 1 && mouseX > PRIMARY_SIDEBAR_WIDTH * 0.8;
82+
83+
// If we deem the user intention is _not_ to active another nav group, add a 200ms delay
84+
if (isMovingTowardSecondaryNav || isSkimmingRightSide) {
85+
return 200;
86+
}
87+
88+
// Otherwise, activate immediately
89+
return 0;
90+
};
91+
92+
timeoutRef.current = setTimeout(() => {
93+
setActivePrimaryNavGroup(group);
94+
}, getDelay());
95+
};
96+
97+
const onMouseLeave = () => {
98+
if (timeoutRef.current) {
99+
clearTimeout(timeoutRef.current);
100+
}
101+
};
102+
103+
const onClick = () => {
104+
setActivePrimaryNavGroup(group);
105+
};
106+
107+
return {
108+
onMouseEnter,
109+
onMouseLeave,
110+
onClick,
111+
};
112+
};
113+
}

0 commit comments

Comments
 (0)