Skip to content

Commit 98e9897

Browse files
authored
feat(nav): Open secondary navigation in overlay when collapsed (#92055)
1 parent e563e8f commit 98e9897

20 files changed

+295
-296
lines changed

static/app/components/hovercard.tsx

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import {Fragment, useCallback, useRef} from 'react';
1+
import {createContext, Fragment, useCallback, useContext, useMemo, useRef} from 'react';
22
import {createPortal} from 'react-dom';
33
import {useTheme} from '@emotion/react';
44
import styled from '@emotion/styled';
5+
import type {State} from '@popperjs/core';
56
import {useResizeObserver} from '@react-aria/utils';
67
import {AnimatePresence} from 'framer-motion';
78

@@ -16,6 +17,10 @@ interface HovercardProps extends Omit<UseHoverOverlayProps, 'isHoverable'> {
1617
* Classname to apply to the hovercard
1718
*/
1819
children: React.ReactNode;
20+
/**
21+
* Whether to animate the hovercard in/out
22+
*/
23+
animated?: boolean;
1924
/**
2025
* Element to display in the body
2126
*/
@@ -32,6 +37,11 @@ interface HovercardProps extends Omit<UseHoverOverlayProps, 'isHoverable'> {
3237
* Element to display in the header
3338
*/
3439
header?: React.ReactNode;
40+
/**
41+
* Container to render the hovercard content
42+
* Defaults to document.body
43+
*/
44+
portalContainer?: HTMLElement;
3545
/**
3646
* Color of the arrow tip border
3747
*/
@@ -47,11 +57,33 @@ type UseOverOverlayState = ReturnType<typeof useHoverOverlay>;
4757
interface HovercardContentProps
4858
extends Pick<
4959
HovercardProps,
50-
'bodyClassName' | 'className' | 'header' | 'body' | 'tipColor' | 'tipBorderColor'
60+
| 'animated'
61+
| 'bodyClassName'
62+
| 'className'
63+
| 'header'
64+
| 'body'
65+
| 'tipColor'
66+
| 'tipBorderColor'
5167
> {
5268
hoverOverlayState: Omit<UseOverOverlayState, 'isOpen' | 'wrapTrigger'>;
5369
}
5470

71+
interface HovercardProviderValue {
72+
isOpen: boolean;
73+
reset: () => void;
74+
update: (() => Promise<Partial<State>>) | null;
75+
}
76+
77+
const HovercardContext = createContext<HovercardProviderValue>({
78+
isOpen: false,
79+
reset: () => {},
80+
update: null,
81+
});
82+
83+
export function useHovercardContext() {
84+
return useContext(HovercardContext);
85+
}
86+
5587
function useUpdateOverlayPositionOnContentChange({
5688
update,
5789
}: Pick<UseOverOverlayState, 'update'>) {
@@ -70,6 +102,7 @@ function useUpdateOverlayPositionOnContentChange({
70102
}
71103

72104
function HovercardContent({
105+
animated,
73106
body,
74107
bodyClassName,
75108
className,
@@ -84,7 +117,7 @@ function HovercardContent({
84117
return (
85118
<PositionWrapper zIndex={theme.zIndex.hovercard} {...overlayProps}>
86119
<StyledHovercard
87-
animated
120+
animated={animated}
88121
arrowProps={{
89122
...arrowProps,
90123
size: 20,
@@ -114,6 +147,8 @@ function Hovercard({
114147
displayTimeout = 100,
115148
tipBorderColor = 'translucentBorder',
116149
tipColor = 'backgroundElevated',
150+
animated = true,
151+
portalContainer = document.body,
117152
...hoverOverlayProps
118153
}: HovercardProps): React.ReactElement {
119154
const {wrapTrigger, isOpen, ...hoverOverlayState} = useHoverOverlay({
@@ -124,16 +159,25 @@ function Hovercard({
124159
...hoverOverlayProps,
125160
});
126161

162+
const contextValue = useMemo<HovercardProviderValue>(
163+
() => ({
164+
isOpen,
165+
reset: hoverOverlayState.reset,
166+
update: hoverOverlayState.update,
167+
}),
168+
[isOpen, hoverOverlayState.reset, hoverOverlayState.update]
169+
);
127170
// Nothing to render if no header or body. Be consistent with wrapping the
128171
// children with the trigger in the case that the body / header is set while
129172
// the trigger is hovered.
130173
if (!body && !header) {
131174
return <Fragment>{wrapTrigger(children)}</Fragment>;
132175
}
133176

134-
const hovercardContent = isOpen && (
177+
const hovercardContent = isOpen ? (
135178
<HovercardContent
136179
{...{
180+
animated,
137181
body,
138182
bodyClassName,
139183
className,
@@ -143,13 +187,19 @@ function Hovercard({
143187
hoverOverlayState,
144188
}}
145189
/>
190+
) : null;
191+
192+
const hovercard = animated ? (
193+
<AnimatePresence>{hovercardContent}</AnimatePresence>
194+
) : (
195+
hovercardContent
146196
);
147197

148198
return (
149-
<Fragment>
199+
<HovercardContext.Provider value={contextValue}>
150200
{wrapTrigger(children)}
151-
{createPortal(<AnimatePresence>{hovercardContent}</AnimatePresence>, document.body)}
152-
</Fragment>
201+
{createPortal(hovercard, portalContainer)}
202+
</HovercardContext.Provider>
153203
);
154204
}
155205

static/app/views/admin/adminLayout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {t} from 'sentry/locale';
55
import type {RouteComponentProps} from 'sentry/types/legacyReactRouter';
66
import useOrganization from 'sentry/utils/useOrganization';
77
import {prefersStackedNav} from 'sentry/views/nav/prefersStackedNav';
8+
import {PrimaryNavGroup} from 'sentry/views/nav/types';
89
import {BreadcrumbProvider} from 'sentry/views/settings/components/settingsBreadcrumb/context';
910
import SettingsLayout from 'sentry/views/settings/components/settingsLayout';
1011
import SettingsNavigation from 'sentry/views/settings/components/settingsNavigation';
@@ -40,6 +41,7 @@ export function AdminNavigation() {
4041
],
4142
},
4243
]}
44+
primaryNavGroup={PrimaryNavGroup.ADMIN}
4345
/>
4446
);
4547
}

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

Lines changed: 32 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,16 @@ describe('Nav', function () {
7878
body: {},
7979
});
8080

81+
MockApiClient.addMockResponse({
82+
url: `/organizations/org-slug/explore/saved/`,
83+
body: [],
84+
});
85+
86+
MockApiClient.addMockResponse({
87+
url: `/organizations/org-slug/dashboards/`,
88+
body: [],
89+
});
90+
8191
ConfigStore.set('user', {
8292
...ConfigStore.get('user'),
8393
options: {
@@ -243,58 +253,48 @@ describe('Nav', function () {
243253

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

246-
expect(screen.getByTestId('collapsed-secondary-sidebar')).toBeInTheDocument();
256+
await waitFor(() => {
257+
expect(
258+
screen.queryByRole('navigation', {name: 'Secondary Navigation'})
259+
).not.toBeInTheDocument();
260+
});
247261

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

250264
expect(
251-
screen.queryByTestId('collapsed-secondary-sidebar')
252-
).not.toBeInTheDocument();
265+
await screen.findByRole('navigation', {name: 'Secondary Navigation'})
266+
).toBeInTheDocument();
267+
268+
await userEvent.click(screen.getByRole('button', {name: 'Expand'}));
253269
});
254270

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

258274
renderNav();
259275

260-
expect(
261-
await screen.findByTestId('collapsed-secondary-sidebar')
262-
).toBeInTheDocument();
263-
expect(screen.getByRole('button', {name: 'Expand'})).toBeInTheDocument();
276+
await waitFor(() => {
277+
expect(
278+
screen.queryByRole('navigation', {name: 'Secondary Navigation'})
279+
).not.toBeInTheDocument();
280+
});
264281
});
265282

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

269286
renderNav();
270287

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

275-
expect(screen.getByTestId('collapsed-secondary-sidebar')).toHaveAttribute(
276-
'data-visible',
277-
'false'
278-
);
290+
await screen.findByRole('navigation', {name: 'Secondary Navigation'});
279291

280-
// Moving pointer over the primary navigation should expand the sidebar
281-
await userEvent.hover(
282-
screen.getByRole('navigation', {name: 'Primary Navigation'})
283-
);
284-
expect(screen.getByTestId('collapsed-secondary-sidebar')).toHaveAttribute(
285-
'data-visible',
286-
'true'
287-
);
292+
await userEvent.click(screen.getByRole('link', {name: 'Traces'}));
288293

289-
// Moving pointer away should hide the sidebar
290-
await userEvent.unhover(
291-
screen.getByRole('navigation', {name: 'Primary Navigation'})
292-
);
293294
await waitFor(() => {
294-
expect(screen.getByTestId('collapsed-secondary-sidebar')).toHaveAttribute(
295-
'data-visible',
296-
'false'
297-
);
295+
expect(
296+
screen.queryByRole('navigation', {name: 'Secondary Navigation'})
297+
).not.toBeInTheDocument();
298298
});
299299
});
300300
});

static/app/views/nav/index.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,21 @@ import styled from '@emotion/styled';
44

55
import {useNavContext} from 'sentry/views/nav/context';
66
import MobileTopbar from 'sentry/views/nav/mobileTopbar';
7+
import {SecondaryNav} from 'sentry/views/nav/secondary/secondary';
78
import {SecondaryNavContent} from 'sentry/views/nav/secondary/secondaryNavContent';
89
import {Sidebar} from 'sentry/views/nav/sidebar';
910
import {
1011
NavigationTourProvider,
1112
useStackedNavigationTour,
1213
} from 'sentry/views/nav/tour/tour';
1314
import {NavLayout} from 'sentry/views/nav/types';
15+
import {useActiveNavGroup} from 'sentry/views/nav/useActiveNavGroup';
1416

1517
function NavContent() {
1618
const {layout, navParentRef} = useNavContext();
1719
const {currentStepId, endTour} = useStackedNavigationTour();
1820
const tourIsActive = currentStepId !== null;
21+
const activeNavGroup = useActiveNavGroup();
1922

2023
// The tour only works with the sidebar layout, so if we change to the mobile
2124
// layout in the middle of the tour, it needs to end.
@@ -32,7 +35,9 @@ function NavContent() {
3235
isMobile={layout === NavLayout.MOBILE}
3336
>
3437
{layout === NavLayout.SIDEBAR ? <Sidebar /> : <MobileTopbar />}
35-
<SecondaryNavContent />
38+
<SecondaryNav>
39+
<SecondaryNavContent group={activeNavGroup} />
40+
</SecondaryNav>
3641
</NavContainer>
3742
);
3843
}

0 commit comments

Comments
 (0)