Files
2024-02-15 17:01:50 +00:00

191 lines
5.8 KiB
TypeScript

import {isValidElement, useEffect, useRef, useState} from 'react';
import type {RouteComponentProps} from 'react-router';
import {browserHistory} from 'react-router';
import styled from '@emotion/styled';
import {Button} from 'sentry/components/button';
import * as Layout from 'sentry/components/layouts/thirds';
import {IconClose, IconMenu} from 'sentry/icons';
import {t} from 'sentry/locale';
import {fadeIn, slideInLeft} from 'sentry/styles/animations';
import {space} from 'sentry/styles/space';
import SettingsBreadcrumb from './settingsBreadcrumb';
import SettingsHeader from './settingsHeader';
import SettingsSearch from './settingsSearch';
type Props = {
children: React.ReactNode;
renderNavigation?: (opts: {isMobileNavVisible: boolean}) => React.ReactNode;
} & RouteComponentProps<{}, {}>;
function SettingsLayout(props: Props) {
// This is used when the screen is small enough that the navigation should be
// hidden. This state is only used when the media query matches.
//
// [!!] On large screens this state is totally unused!
const [isMobileNavVisible, setMobileNavVisible] = useState(false);
// Offset mobile settings navigation by the height of main navigation,
// settings breadcrumbs and optional warnings.
const [navOffsetTop, setNavOffsetTop] = useState(0);
const headerRef = useRef<HTMLDivElement>(null);
function toggleNav(visible: boolean) {
const bodyElement = document.getElementsByTagName('body')[0];
window.scrollTo?.(0, 0);
bodyElement.classList[visible ? 'add' : 'remove']('scroll-lock');
setMobileNavVisible(visible);
setNavOffsetTop(headerRef.current?.getBoundingClientRect().bottom ?? 0);
}
// Close menu when navigating away
useEffect(() => browserHistory.listen(() => toggleNav(false)), []);
const {renderNavigation, children, params, routes, route} = props;
// We want child's view's props
const childProps = children && isValidElement(children) ? children.props : props;
const childRoutes = childProps.routes || routes || [];
const childRoute = childProps.route || route || {};
const shouldRenderNavigation = typeof renderNavigation === 'function';
return (
<SettingsColumn>
<SettingsHeader ref={headerRef}>
<HeaderContent>
{shouldRenderNavigation && (
<NavMenuToggle
priority="link"
aria-label={isMobileNavVisible ? t('Close the menu') : t('Open the menu')}
icon={
isMobileNavVisible ? <IconClose aria-hidden /> : <IconMenu aria-hidden />
}
onClick={() => toggleNav(!isMobileNavVisible)}
/>
)}
<StyledSettingsBreadcrumb
params={params}
routes={childRoutes}
route={childRoute}
/>
<SettingsSearch />
</HeaderContent>
</SettingsHeader>
<MaxWidthContainer>
{shouldRenderNavigation && (
<SidebarWrapper
aria-label={t('Settings Navigation')}
isVisible={isMobileNavVisible}
offsetTop={navOffsetTop}
>
{renderNavigation({isMobileNavVisible})}
</SidebarWrapper>
)}
<NavMask isVisible={isMobileNavVisible} onClick={() => toggleNav(false)} />
<Content>{children}</Content>
</MaxWidthContainer>
</SettingsColumn>
);
}
const SettingsColumn = styled('div')`
display: flex;
flex-direction: column;
flex: 1; /* so this stretches vertically so that footer is fixed at bottom */
min-width: 0; /* fixes problem when child content stretches beyond layout width */
footer {
margin-top: 0;
}
`;
const HeaderContent = styled('div')`
display: flex;
align-items: center;
justify-content: space-between;
`;
const NavMenuToggle = styled(Button)`
display: none;
margin: -${space(1)} ${space(1)} -${space(1)} -${space(1)};
padding: ${space(1)};
color: ${p => p.theme.subText};
&:hover,
&:focus,
&:active {
color: ${p => p.theme.textColor};
}
@media (max-width: ${p => p.theme.breakpoints.medium}) {
display: block;
}
`;
const StyledSettingsBreadcrumb = styled(SettingsBreadcrumb)`
flex: 1;
`;
const MaxWidthContainer = styled('div')`
display: flex;
max-width: ${p => p.theme.settings.containerWidth};
flex: 1;
`;
const SidebarWrapper = styled('nav')<{isVisible: boolean; offsetTop: number}>`
flex-shrink: 0;
width: ${p => p.theme.settings.sidebarWidth};
background: ${p => p.theme.background};
border-right: 1px solid ${p => p.theme.border};
@media (max-width: ${p => p.theme.breakpoints.medium}) {
display: ${p => (p.isVisible ? 'block' : 'none')};
position: fixed;
top: ${p => p.offsetTop}px;
bottom: 0;
overflow-y: auto;
animation: ${slideInLeft} 100ms ease-in-out;
z-index: ${p => p.theme.zIndex.settingsSidebarNav};
box-shadow: ${p => p.theme.dropShadowHeavy};
}
`;
const NavMask = styled('div')<{isVisible: boolean}>`
display: none;
@media (max-width: ${p => p.theme.breakpoints.medium}) {
display: ${p => (p.isVisible ? 'block' : 'none')};
background: rgba(0, 0, 0, 0.35);
height: 100%;
width: 100%;
position: absolute;
z-index: ${p => p.theme.zIndex.settingsSidebarNavMask};
animation: ${fadeIn} 250ms ease-in-out;
}
`;
/**
* Note: `overflow: hidden` will cause some buttons in `SettingsPageHeader` to be cut off because it has negative margin.
* Will also cut off tooltips.
*/
const Content = styled('div')`
flex: 1;
padding: ${space(4)};
min-width: 0; /* keep children from stretching container */
@media (max-width: ${p => p.theme.breakpoints.medium}) {
padding: ${space(2)};
}
/**
* Layout.Page is not normally used in settings but <PermissionDenied /> uses
* it under the hood. This prevents double padding.
*/
${Layout.Page} {
padding: 0;
}
`;
export default SettingsLayout;