Skip to content

feat(nav): Improve preview of other nav groups on hover #92352

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions static/app/views/nav/constants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@ export const NAV_SIDEBAR_COLLAPSED_LOCAL_STORAGE_KEY = 'navigation-sidebar-is-co

export const PRIMARY_SIDEBAR_WIDTH = 74;
export const SECONDARY_SIDEBAR_WIDTH = 190;

// Slightly delay closing the nav to prevent accidental dismissal
export const NAV_SIDEBAR_COLLAPSE_DELAY_MS = 200;
11 changes: 11 additions & 0 deletions static/app/views/nav/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@ import {useTheme} from '@emotion/react';
import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
import useMedia from 'sentry/utils/useMedia';
import {NAV_SIDEBAR_COLLAPSED_LOCAL_STORAGE_KEY} from 'sentry/views/nav/constants';
import type {PrimaryNavGroup} from 'sentry/views/nav/types';
import {NavLayout} from 'sentry/views/nav/types';

interface NavContext {
activePrimaryNavGroup: PrimaryNavGroup | null;
endInteraction: () => void;
isCollapsed: boolean;
isInteractingRef: React.RefObject<boolean | null>;
layout: NavLayout;
navParentRef: React.RefObject<HTMLDivElement | null>;
setActivePrimaryNavGroup: (activePrimaryNavGroup: PrimaryNavGroup | null) => void;
setIsCollapsed: (isCollapsed: boolean) => void;
setShowTourReminder: (showTourReminder: boolean) => void;
showTourReminder: boolean;
Expand All @@ -28,6 +31,8 @@ const NavContext = createContext<NavContext>({
endInteraction: () => {},
showTourReminder: false,
setShowTourReminder: () => {},
activePrimaryNavGroup: null,
setActivePrimaryNavGroup: () => {},
});

export function useNavContext(): NavContext {
Expand All @@ -42,6 +47,8 @@ export function NavContextProvider({children}: {children: React.ReactNode}) {
false
);
const [showTourReminder, setShowTourReminder] = useState(false);
const [activePrimaryNavGroup, setActivePrimaryNavGroup] =
useState<PrimaryNavGroup | null>(null);

const theme = useTheme();
const isMobile = useMedia(`(max-width: ${theme.breakpoints.medium})`);
Expand All @@ -65,6 +72,8 @@ export function NavContextProvider({children}: {children: React.ReactNode}) {
endInteraction,
showTourReminder,
setShowTourReminder,
activePrimaryNavGroup,
setActivePrimaryNavGroup,
}),
[
isMobile,
Expand All @@ -74,6 +83,8 @@ export function NavContextProvider({children}: {children: React.ReactNode}) {
endInteraction,
showTourReminder,
setShowTourReminder,
activePrimaryNavGroup,
setActivePrimaryNavGroup,
]
);

Expand Down
64 changes: 42 additions & 22 deletions static/app/views/nav/index.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,16 @@ describe('Nav', function () {
).toBeInTheDocument();
});

it('previews secondary nav when hovering over other primary items', async function () {
renderNav();

await userEvent.hover(screen.getByRole('link', {name: 'Explore'}));
await screen.findByRole('link', {name: 'Traces'});

await userEvent.hover(screen.getByRole('link', {name: 'Dashboards'}));
await screen.findByRole('link', {name: 'All Dashboards'});
});

describe('sections', function () {
it('renders organization/account settings secondary nav when on settings routes', function () {
renderNav({initialPathname: '/settings/organization/'});
Expand Down Expand Up @@ -253,48 +263,58 @@ describe('Nav', function () {

await userEvent.click(screen.getByRole('button', {name: 'Collapse'}));

await waitFor(() => {
expect(
screen.queryByRole('navigation', {name: 'Secondary Navigation'})
).not.toBeInTheDocument();
});
expect(screen.getByTestId('collapsed-secondary-sidebar')).toBeInTheDocument();

await userEvent.hover(screen.getByRole('link', {name: 'Issues'}));
await userEvent.click(screen.getByRole('button', {name: 'Expand'}));

expect(
await screen.findByRole('navigation', {name: 'Secondary Navigation'})
).toBeInTheDocument();

await userEvent.click(screen.getByRole('button', {name: 'Expand'}));
screen.queryByTestId('collapsed-secondary-sidebar')
).not.toBeInTheDocument();
});

it('remembers collapsed state', async function () {
localStorage.setItem(NAV_SIDEBAR_COLLAPSED_LOCAL_STORAGE_KEY, 'true');

renderNav();

await waitFor(() => {
expect(
screen.queryByRole('navigation', {name: 'Secondary Navigation'})
).not.toBeInTheDocument();
});
expect(
await screen.findByTestId('collapsed-secondary-sidebar')
).toBeInTheDocument();
expect(screen.getByRole('button', {name: 'Expand'})).toBeInTheDocument();
});

it('closes secondary nav overlay when navigating to a new route', async function () {
it('expands on hover', async function () {
localStorage.setItem(NAV_SIDEBAR_COLLAPSED_LOCAL_STORAGE_KEY, 'true');

renderNav();

await userEvent.hover(screen.getByRole('link', {name: 'Explore'}));
expect(
await screen.findByTestId('collapsed-secondary-sidebar')
).toBeInTheDocument();

await screen.findByRole('navigation', {name: 'Secondary Navigation'});
expect(screen.getByTestId('collapsed-secondary-sidebar')).toHaveAttribute(
'data-visible',
'false'
);

await userEvent.click(screen.getByRole('link', {name: 'Traces'}));
// Moving pointer over the primary navigation should expand the sidebar
await userEvent.hover(
screen.getByRole('navigation', {name: 'Primary Navigation'})
);
expect(screen.getByTestId('collapsed-secondary-sidebar')).toHaveAttribute(
'data-visible',
'true'
);

// Moving pointer away should hide the sidebar
await userEvent.unhover(
screen.getByRole('navigation', {name: 'Primary Navigation'})
);
await waitFor(() => {
expect(
screen.queryByRole('navigation', {name: 'Secondary Navigation'})
).not.toBeInTheDocument();
expect(screen.getByTestId('collapsed-secondary-sidebar')).toHaveAttribute(
'data-visible',
'false'
);
});
});
});
Expand Down
3 changes: 3 additions & 0 deletions static/app/views/nav/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ import {
useStackedNavigationTour,
} from 'sentry/views/nav/tour/tour';
import {NavLayout} from 'sentry/views/nav/types';
import {useResetActiveNavGroup} from 'sentry/views/nav/useResetActiveNavGroup';

function NavContent() {
const {layout, navParentRef} = useNavContext();
const {currentStepId, endTour} = useStackedNavigationTour();
const tourIsActive = currentStepId !== null;
const hoverProps = useResetActiveNavGroup();

// The tour only works with the sidebar layout, so if we change to the mobile
// layout in the middle of the tour, it needs to end.
Expand All @@ -29,6 +31,7 @@ function NavContent() {
ref={navParentRef}
tourIsActive={tourIsActive}
isMobile={layout === NavLayout.MOBILE}
{...hoverProps}
>
{layout === NavLayout.SIDEBAR ? <Sidebar /> : <MobileTopbar />}
</NavContainer>
Expand Down
46 changes: 24 additions & 22 deletions static/app/views/nav/primary/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import {Fragment, type MouseEventHandler} from 'react';
import type {Theme} from '@emotion/react';
import {css, useTheme} from '@emotion/react';
import styled from '@emotion/styled';
import {useHover} from '@react-aria/interactions';

import {Button} from 'sentry/components/core/button';
import {Tooltip} from 'sentry/components/core/tooltip';
import {DropdownMenu, type MenuItemProps} from 'sentry/components/dropdownMenu';
import {useHovercardContext} from 'sentry/components/hovercard';
import InteractionStateLayer from 'sentry/components/interactionStateLayer';
import Link, {linkStyles} from 'sentry/components/links/link';
import {SIDEBAR_NAVIGATION_SOURCE} from 'sentry/components/sidebar/utils';
Expand All @@ -22,7 +22,6 @@ import useOrganization from 'sentry/utils/useOrganization';
import {PRIMARY_SIDEBAR_WIDTH} from 'sentry/views/nav/constants';
import {useNavContext} from 'sentry/views/nav/context';
import {PRIMARY_NAV_GROUP_CONFIG} from 'sentry/views/nav/primary/config';
import {SecondaryHovercard} from 'sentry/views/nav/primary/secondaryHovercard';
import type {PrimaryNavGroup} from 'sentry/views/nav/types';
import {NavLayout} from 'sentry/views/nav/types';
import {isLinkActive} from 'sentry/views/nav/utils';
Expand Down Expand Up @@ -148,26 +147,29 @@ function SidebarNavLink({
group,
}: SidebarItemLinkProps) {
const organization = useOrganization();
const {reset: closeCollapsedNavHovercard} = useHovercardContext();
const {layout} = useNavContext();
const {layout, activePrimaryNavGroup, setActivePrimaryNavGroup} = useNavContext();
const theme = useTheme();
const location = useLocation();
const isActive = isLinkActive(normalizeUrl(activeTo, location), location.pathname);
const label = PRIMARY_NAV_GROUP_CONFIG[group].label;

const {hoverProps} = useHover({
onHoverStart: () => {
setActivePrimaryNavGroup(group);
},
});

return (
<NavLink
to={to}
state={{source: SIDEBAR_NAVIGATION_SOURCE}}
onClick={() => {
recordPrimaryItemClick(analyticsKey, organization);

// When the nav is collapsed, clicking on a link will close the hovercard.
closeCollapsedNavHovercard();
}}
aria-selected={isActive}
aria-selected={activePrimaryNavGroup === group ? true : isActive}
aria-current={isActive ? 'page' : undefined}
isMobile={layout === NavLayout.MOBILE}
{...hoverProps}
>
{layout === NavLayout.MOBILE ? (
<Fragment>
Expand Down Expand Up @@ -196,16 +198,14 @@ export function SidebarLink({

return (
<SidebarItem label={label} showLabel>
<SecondaryHovercard group={group}>
<SidebarNavLink
to={to}
activeTo={activeTo}
analyticsKey={analyticsKey}
group={group}
>
{children}
</SidebarNavLink>
</SecondaryHovercard>
<SidebarNavLink
to={to}
activeTo={activeTo}
analyticsKey={analyticsKey}
group={group}
>
{children}
</SidebarNavLink>
</SidebarItem>
);
}
Expand Down Expand Up @@ -413,14 +413,15 @@ const ChonkNavLink = chonkStyled(Link, {
}
}

&:hover {
&:hover,
&[aria-selected='true'] {
color: ${p => p.theme.tokens.content.muted};
${NavLinkIconContainer} {
background-color: ${p => p.theme.colors.gray100};
}
}

&[aria-selected='true'] {
&[aria-current='page'] {
color: ${p => p.theme.purple400};

&::before { opacity: 1; }
Expand Down Expand Up @@ -450,7 +451,8 @@ const StyledNavLink = styled(Link, {
padding-bottom: ${space(1)};
gap: ${space(0.5)};

&:hover {
&:hover,
&[aria-selected='true'] {
${NavLinkIconContainer} {
&::before {
opacity: 0.06;
Expand All @@ -466,7 +468,7 @@ const StyledNavLink = styled(Link, {
}
}

&[aria-selected='true'] {
&[aria-current='page'] {
color: ${p.theme.purple400};

${NavLinkIconContainer} {
Expand Down
58 changes: 0 additions & 58 deletions static/app/views/nav/primary/secondaryHovercard.tsx

This file was deleted.

Loading
Loading