Implement Expedias new Scalable Side Nav design - items with submenus and expand on click
Signed-off-by: hiba-aldalaty <hibaaldalaty@gmail.com>
This commit is contained in:
@@ -40,6 +40,7 @@ import {
|
||||
SidebarDivider,
|
||||
SidebarSpace,
|
||||
SidebarScrollWrapper,
|
||||
SidebarExpandButton,
|
||||
} from '@backstage/core-components';
|
||||
|
||||
const useSidebarLogoStyles = makeStyles({
|
||||
@@ -98,6 +99,7 @@ export const Root = ({ children }: PropsWithChildren<{}>) => (
|
||||
<SidebarDivider />
|
||||
<Shortcuts />
|
||||
<SidebarSpace />
|
||||
<SidebarExpandButton />
|
||||
<SidebarDivider />
|
||||
<SidebarSettings />
|
||||
</Sidebar>
|
||||
|
||||
@@ -16,10 +16,17 @@
|
||||
|
||||
import { makeStyles, useMediaQuery } from '@material-ui/core';
|
||||
import clsx from 'clsx';
|
||||
import React, { useRef, useState, useContext, PropsWithChildren } from 'react';
|
||||
import React, {
|
||||
useState,
|
||||
useContext,
|
||||
PropsWithChildren,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import { sidebarConfig, SidebarContext } from './config';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
import { SidebarPinStateContext } from './Page';
|
||||
import DoubleArrowRight from './icons/DoubleArrowRight';
|
||||
import DoubleArrowLeft from './icons/DoubleArrowLeft';
|
||||
|
||||
export type SidebarClassKey = 'root' | 'drawer' | 'drawerOpen';
|
||||
|
||||
@@ -45,7 +52,6 @@ const useStyles = makeStyles<BackstageTheme>(
|
||||
msOverflowStyle: 'none',
|
||||
scrollbarWidth: 'none',
|
||||
width: sidebarConfig.drawerWidthClosed,
|
||||
borderRight: `1px solid #383838`,
|
||||
transition: theme.transitions.create('width', {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.shortest,
|
||||
@@ -57,6 +63,19 @@ const useStyles = makeStyles<BackstageTheme>(
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
expandButton: {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: theme.palette.navigation.color,
|
||||
width: '100%',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
height: 48,
|
||||
},
|
||||
arrows: {
|
||||
position: 'absolute',
|
||||
right: 10,
|
||||
},
|
||||
drawerOpen: {
|
||||
width: sidebarConfig.drawerWidthOpen,
|
||||
transition: theme.transitions.create('width', {
|
||||
@@ -68,81 +87,24 @@ const useStyles = makeStyles<BackstageTheme>(
|
||||
{ name: 'BackstageSidebar' },
|
||||
);
|
||||
|
||||
enum State {
|
||||
Closed,
|
||||
Idle,
|
||||
Open,
|
||||
}
|
||||
|
||||
type Props = {
|
||||
openDelayMs?: number;
|
||||
closeDelayMs?: number;
|
||||
};
|
||||
|
||||
export function Sidebar(props: PropsWithChildren<Props>) {
|
||||
const {
|
||||
openDelayMs = sidebarConfig.defaultOpenDelayMs,
|
||||
closeDelayMs = sidebarConfig.defaultCloseDelayMs,
|
||||
children,
|
||||
} = props;
|
||||
export function Sidebar({ children }: PropsWithChildren<{}>) {
|
||||
const classes = useStyles();
|
||||
const isSmallScreen = useMediaQuery<BackstageTheme>(theme =>
|
||||
theme.breakpoints.down('md'),
|
||||
);
|
||||
const [state, setState] = useState(State.Closed);
|
||||
const hoverTimerRef = useRef<number>();
|
||||
|
||||
const { isPinned } = useContext(SidebarPinStateContext);
|
||||
const [isOpen, setIsOpen] = useState(isPinned);
|
||||
|
||||
const handleOpen = () => {
|
||||
useEffect(() => {
|
||||
if (isPinned) {
|
||||
return;
|
||||
setIsOpen(true);
|
||||
}
|
||||
if (hoverTimerRef.current) {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
hoverTimerRef.current = undefined;
|
||||
}
|
||||
if (state !== State.Open && !isSmallScreen) {
|
||||
hoverTimerRef.current = window.setTimeout(() => {
|
||||
hoverTimerRef.current = undefined;
|
||||
setState(State.Open);
|
||||
}, openDelayMs);
|
||||
|
||||
setState(State.Idle);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (isPinned) {
|
||||
return;
|
||||
}
|
||||
if (hoverTimerRef.current) {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
hoverTimerRef.current = undefined;
|
||||
}
|
||||
if (state === State.Idle) {
|
||||
setState(State.Closed);
|
||||
} else if (state === State.Open) {
|
||||
hoverTimerRef.current = window.setTimeout(() => {
|
||||
hoverTimerRef.current = undefined;
|
||||
setState(State.Closed);
|
||||
}, closeDelayMs);
|
||||
}
|
||||
};
|
||||
|
||||
const isOpen = (state === State.Open && !isSmallScreen) || isPinned;
|
||||
}, [isPinned]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classes.root}
|
||||
onMouseEnter={handleOpen}
|
||||
onFocus={handleOpen}
|
||||
onMouseLeave={handleClose}
|
||||
onBlur={handleClose}
|
||||
data-testid="sidebar-root"
|
||||
>
|
||||
<div className={classes.root} data-testid="sidebar-root">
|
||||
<SidebarContext.Provider
|
||||
value={{
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -156,3 +118,37 @@ export function Sidebar(props: PropsWithChildren<Props>) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const SidebarExpandButton = () => {
|
||||
const classes = useStyles();
|
||||
const isSmallScreen = useMediaQuery<BackstageTheme>(theme =>
|
||||
theme.breakpoints.down('md'),
|
||||
);
|
||||
const { isPinned } = useContext(SidebarPinStateContext);
|
||||
const { isOpen, setIsOpen } = useContext(SidebarContext);
|
||||
|
||||
const openDelayMs = sidebarConfig.defaultOpenDelayMs;
|
||||
const closeDelayMs = sidebarConfig.defaultCloseDelayMs;
|
||||
|
||||
const handleClick = () => {
|
||||
if (isPinned || isSmallScreen) {
|
||||
return;
|
||||
}
|
||||
const delayMs = isOpen ? openDelayMs : closeDelayMs;
|
||||
setTimeout(async () => {
|
||||
setIsOpen(!isOpen);
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
if (isSmallScreen || isPinned) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button onClick={handleClick} className={classes.expandButton}>
|
||||
<div className={classes.arrows}>
|
||||
{isOpen ? <DoubleArrowLeft /> : <DoubleArrowRight />}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,11 +25,14 @@ import {
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import { CreateCSSProperties } from '@material-ui/core/styles/withStyles';
|
||||
import ArrowRightIcon from '@material-ui/icons/ArrowRight';
|
||||
import SearchIcon from '@material-ui/icons/Search';
|
||||
import clsx from 'clsx';
|
||||
import React, {
|
||||
Children,
|
||||
forwardRef,
|
||||
KeyboardEventHandler,
|
||||
PropsWithChildren,
|
||||
ReactNode,
|
||||
useContext,
|
||||
useState,
|
||||
@@ -37,10 +40,16 @@ import React, {
|
||||
import {
|
||||
Link,
|
||||
NavLinkProps,
|
||||
resolvePath,
|
||||
useLocation,
|
||||
useResolvedPath,
|
||||
} from 'react-router-dom';
|
||||
import { sidebarConfig, SidebarContext } from './config';
|
||||
import {
|
||||
sidebarConfig,
|
||||
SidebarContext,
|
||||
ItemWithSubmenuContext,
|
||||
} from './config';
|
||||
import { Submenu } from './Submenu';
|
||||
|
||||
export type SidebarItemClassKey =
|
||||
| 'root'
|
||||
@@ -89,6 +98,14 @@ const useStyles = makeStyles<BackstageTheme>(
|
||||
open: {
|
||||
width: drawerWidthOpen,
|
||||
},
|
||||
highlightable: {
|
||||
'&:hover': {
|
||||
background: theme.palette.navigation.navItem.hoverBackground, // TODO: consider
|
||||
},
|
||||
},
|
||||
highlighted: {
|
||||
background: theme.palette.navigation.navItem.hoverBackground, // TODO: consider
|
||||
},
|
||||
label: {
|
||||
// XXX (@koroeskohr): I can't seem to achieve the desired font-weight from the designs
|
||||
fontWeight: 'bold',
|
||||
@@ -127,13 +144,24 @@ const useStyles = makeStyles<BackstageTheme>(
|
||||
textAlign: 'center',
|
||||
marginRight: theme.spacing(1),
|
||||
},
|
||||
closedItemIcon: {
|
||||
width: '100%',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
submenuArrow: {
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
},
|
||||
selected: {
|
||||
'&$root': {
|
||||
borderLeft: `solid ${selectedIndicatorWidth}px ${theme.palette.navigation.indicator}`,
|
||||
color: theme.palette.navigation.selectedColor,
|
||||
},
|
||||
'&$closed': {
|
||||
width: drawerWidthClosed - selectedIndicatorWidth,
|
||||
width: drawerWidthClosed,
|
||||
},
|
||||
'& $closedItemIcon': {
|
||||
paddingRight: selectedIndicatorWidth,
|
||||
},
|
||||
'& $iconContainer': {
|
||||
marginLeft: -selectedIndicatorWidth,
|
||||
@@ -144,10 +172,117 @@ const useStyles = makeStyles<BackstageTheme>(
|
||||
{ name: 'BackstageSidebarItem' },
|
||||
);
|
||||
|
||||
type ItemWithSubmenuProps = {
|
||||
label?: string;
|
||||
title?: string;
|
||||
hasNotifications?: boolean;
|
||||
icon: IconComponent;
|
||||
children: ReactNode;
|
||||
};
|
||||
const ItemWithSubmenu = ({
|
||||
label,
|
||||
title,
|
||||
hasNotifications = false,
|
||||
icon: Icon,
|
||||
children,
|
||||
}: PropsWithChildren<ItemWithSubmenuProps>) => {
|
||||
const classes = useStyles();
|
||||
const [isHoveredOn, setIsHoveredOn] = useState(false);
|
||||
const toPathnames: string[] = [];
|
||||
|
||||
let isActive;
|
||||
const { pathname: locationPathname } = useLocation();
|
||||
|
||||
// Menu item is active if any of its children have active paths
|
||||
Children.forEach(children, element => {
|
||||
if (!React.isValidElement(element)) return;
|
||||
if (element.props.hasDropDown && element.props.dropdownItems) {
|
||||
element.props.dropdownItems.map((item: { to: string }) =>
|
||||
toPathnames.push(item.to),
|
||||
);
|
||||
} else if (element.props.to) {
|
||||
toPathnames.push(element.props.to);
|
||||
}
|
||||
});
|
||||
toPathnames.some(to => {
|
||||
const toPathname = resolvePath(to);
|
||||
isActive = locationPathname === toPathname.pathname;
|
||||
return isActive;
|
||||
});
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setIsHoveredOn(true);
|
||||
};
|
||||
const handleMouseLeave = () => {
|
||||
setIsHoveredOn(false);
|
||||
};
|
||||
|
||||
const { isOpen } = useContext(SidebarContext);
|
||||
const itemIcon = (
|
||||
<Badge
|
||||
color="secondary"
|
||||
variant="dot"
|
||||
overlap="circular"
|
||||
className={isOpen ? undefined : classes.closedItemIcon}
|
||||
invisible={!hasNotifications}
|
||||
>
|
||||
<Icon fontSize="small" />
|
||||
</Badge>
|
||||
);
|
||||
const openContent = (
|
||||
<>
|
||||
<div data-testid="login-button" className={classes.iconContainer}>
|
||||
{itemIcon}
|
||||
</div>
|
||||
{label && (
|
||||
<Typography variant="subtitle2" className={classes.label}>
|
||||
{label}
|
||||
</Typography>
|
||||
)}
|
||||
<div className={classes.secondaryAction}>{}</div>
|
||||
</>
|
||||
);
|
||||
const closedContent = itemIcon;
|
||||
|
||||
return (
|
||||
<ItemWithSubmenuContext.Provider
|
||||
value={{
|
||||
isHoveredOn,
|
||||
setIsHoveredOn,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={clsx(isHoveredOn ? classes.highlighted : undefined)}
|
||||
>
|
||||
<div
|
||||
onMouseEnter={handleMouseEnter}
|
||||
className={clsx(
|
||||
classes.root,
|
||||
isOpen ? classes.open : classes.closed,
|
||||
isActive ? classes.selected : undefined,
|
||||
classes.highlightable,
|
||||
isHoveredOn ? classes.highlighted : undefined,
|
||||
)}
|
||||
>
|
||||
{isOpen ? openContent : closedContent}
|
||||
{!isHoveredOn && (
|
||||
<ArrowRightIcon fontSize="small" className={classes.submenuArrow} />
|
||||
)}
|
||||
</div>
|
||||
{isHoveredOn && <Submenu title={title}>{children}</Submenu>}
|
||||
</div>
|
||||
</ItemWithSubmenuContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
type SidebarItemBaseProps = {
|
||||
icon: IconComponent;
|
||||
text?: string;
|
||||
hasNotifications?: boolean;
|
||||
hasSubMenu?: boolean;
|
||||
submenuTitle?: string;
|
||||
disableHighlight?: boolean;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
@@ -222,6 +357,9 @@ export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
|
||||
icon: Icon,
|
||||
text,
|
||||
hasNotifications = false,
|
||||
hasSubMenu = false,
|
||||
submenuTitle,
|
||||
disableHighlight = false,
|
||||
onClick,
|
||||
children,
|
||||
className,
|
||||
@@ -239,6 +377,7 @@ export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
|
||||
variant="dot"
|
||||
overlap="circular"
|
||||
invisible={!hasNotifications}
|
||||
className={clsx(isOpen ? undefined : classes.closedItemIcon)}
|
||||
>
|
||||
<Icon fontSize="small" />
|
||||
</Badge>
|
||||
@@ -248,7 +387,7 @@ export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
|
||||
|
||||
const openContent = (
|
||||
<>
|
||||
<div data-testid="login-button" className={classes.iconContainer}>
|
||||
<div data-testid="login-button" className={clsx(classes.iconContainer)}>
|
||||
{itemIcon}
|
||||
</div>
|
||||
{text && (
|
||||
@@ -269,9 +408,24 @@ export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
|
||||
classes.root,
|
||||
isOpen ? classes.open : classes.closed,
|
||||
isButtonItem(props) && classes.buttonItem,
|
||||
disableHighlight ? undefined : classes.highlightable,
|
||||
),
|
||||
};
|
||||
|
||||
// If submenu prop is true, return ItemWithSubmenu
|
||||
if (hasSubMenu) {
|
||||
return (
|
||||
<ItemWithSubmenu
|
||||
label={text}
|
||||
title={submenuTitle}
|
||||
icon={Icon}
|
||||
hasNotifications={hasNotifications}
|
||||
>
|
||||
{children}
|
||||
</ItemWithSubmenu>
|
||||
);
|
||||
}
|
||||
|
||||
if (isButtonItem(props)) {
|
||||
return (
|
||||
<button aria-label={text} {...childProps} ref={ref}>
|
||||
@@ -332,7 +486,12 @@ export function SidebarSearchField(props: SidebarSearchFieldProps) {
|
||||
|
||||
return (
|
||||
<div className={classes.searchRoot}>
|
||||
<SidebarItem icon={SearchIcon} to={props.to} onClick={handleItemClick}>
|
||||
<SidebarItem
|
||||
icon={SearchIcon}
|
||||
to={props.to}
|
||||
onClick={handleItemClick}
|
||||
disableHighlight
|
||||
>
|
||||
<TextField
|
||||
placeholder="Search"
|
||||
value={input}
|
||||
|
||||
@@ -16,16 +16,25 @@
|
||||
|
||||
import AddCircleOutlineIcon from '@material-ui/icons/AddCircleOutline';
|
||||
import HomeOutlinedIcon from '@material-ui/icons/HomeOutlined';
|
||||
import BuildRoundedIcon from '@material-ui/icons/BuildRounded';
|
||||
import LibraryBooksOutlinedIcon from '@material-ui/icons/LibraryBooksOutlined';
|
||||
import WebOutlinedIcon from '@material-ui/icons/WebOutlined';
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarDivider,
|
||||
SidebarExpandButton,
|
||||
SidebarIntro,
|
||||
SidebarItem,
|
||||
SidebarSearchField,
|
||||
SidebarSpace,
|
||||
} from '.';
|
||||
import { SubmenuItem } from './SubmenuItem';
|
||||
import { CatalogSidebarLogo } from './icons/CatalogSidebarLogo';
|
||||
import { APIsIcon } from './icons/APIsIcon';
|
||||
import { ServicesIcon } from './icons/ServicesIcon';
|
||||
import { MiscIcon } from './icons/MiscIcon';
|
||||
|
||||
export default {
|
||||
title: 'Layout/Sidebar',
|
||||
@@ -46,11 +55,40 @@ export const SampleSidebar = () => (
|
||||
<Sidebar>
|
||||
<SidebarSearchField onSearch={handleSearch} to="/search" />
|
||||
<SidebarDivider />
|
||||
<SidebarItem icon={HomeOutlinedIcon} to="#" text="Home" />
|
||||
<SidebarItem
|
||||
icon={CatalogSidebarLogo}
|
||||
onClick={() => {}}
|
||||
text="Catalog"
|
||||
hasSubMenu
|
||||
submenuTitle="Catalog"
|
||||
>
|
||||
<SubmenuItem title="Tools" to="/1" icon={BuildRoundedIcon} />
|
||||
<SubmenuItem title="APIs" to="/2" icon={APIsIcon} />
|
||||
<SubmenuItem title="Services" to="/3" icon={ServicesIcon} />
|
||||
<SubmenuItem title="Libraries" to="/4" icon={LibraryBooksOutlinedIcon} />
|
||||
<SubmenuItem title="Websites" to="/5" icon={WebOutlinedIcon} />
|
||||
<SubmenuItem
|
||||
title="Misc"
|
||||
to="/6"
|
||||
icon={MiscIcon}
|
||||
hasDropDown
|
||||
dropdownItems={[
|
||||
{
|
||||
title: 'Lorem Ipsum',
|
||||
to: '/7',
|
||||
},
|
||||
{
|
||||
title: 'Lorem Ipsum',
|
||||
to: '/8',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SidebarItem>
|
||||
<SidebarItem icon={HomeOutlinedIcon} to="#" text="Plugins" />
|
||||
<SidebarItem icon={AddCircleOutlineIcon} to="#" text="Create..." />
|
||||
<SidebarDivider />
|
||||
<SidebarIntro />
|
||||
<SidebarSpace />
|
||||
<SidebarExpandButton />
|
||||
</Sidebar>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { makeStyles, Typography } from '@material-ui/core';
|
||||
import clsx from 'clsx';
|
||||
import React, { PropsWithChildren, ReactNode, useContext } from 'react';
|
||||
import {
|
||||
ItemWithSubmenuContext,
|
||||
sidebarConfig,
|
||||
SidebarContext,
|
||||
submenuConfig,
|
||||
} from './config';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
|
||||
const useStyles = (props: { left: number }) =>
|
||||
makeStyles<BackstageTheme>(theme => ({
|
||||
root: {
|
||||
zIndex: 1000,
|
||||
position: 'relative',
|
||||
overflow: 'visible',
|
||||
width: theme.spacing(7) + 1,
|
||||
},
|
||||
drawer: {
|
||||
display: 'flex',
|
||||
flexFlow: 'column nowrap',
|
||||
alignItems: 'flex-start',
|
||||
position: 'fixed',
|
||||
left: props.left,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
padding: 0,
|
||||
background: theme.palette.navigation.submenu.background,
|
||||
overflowX: 'hidden',
|
||||
msOverflowStyle: 'none',
|
||||
scrollbarWidth: 'none',
|
||||
cursor: 'default',
|
||||
width: submenuConfig.drawerWidthClosed,
|
||||
borderRight: `1px solid #383838`,
|
||||
'& > *': {
|
||||
flexShrink: 0,
|
||||
},
|
||||
'&::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
drawerOpen: {
|
||||
width: submenuConfig.drawerWidthOpen,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 500,
|
||||
color: '#FFF',
|
||||
padding: 20,
|
||||
},
|
||||
}));
|
||||
|
||||
type SubmenuProps = {
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
export const Submenu = ({
|
||||
title,
|
||||
children,
|
||||
}: PropsWithChildren<SubmenuProps>) => {
|
||||
const { isOpen } = useContext(SidebarContext);
|
||||
const left = isOpen
|
||||
? sidebarConfig.drawerWidthOpen
|
||||
: sidebarConfig.drawerWidthClosed;
|
||||
const props = { left: left };
|
||||
const classes = useStyles(props)();
|
||||
|
||||
const { isHoveredOn } = useContext(ItemWithSubmenuContext);
|
||||
return (
|
||||
<div
|
||||
className={clsx(classes.drawer, {
|
||||
[classes.drawerOpen]: isHoveredOn,
|
||||
})}
|
||||
>
|
||||
<Typography variant="h5" className={classes.title}>
|
||||
{title}
|
||||
</Typography>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React, { useContext, useState } from 'react';
|
||||
import {
|
||||
NavLink,
|
||||
resolvePath,
|
||||
useLocation,
|
||||
useResolvedPath,
|
||||
} from 'react-router-dom';
|
||||
import { Link, makeStyles, Typography } from '@material-ui/core';
|
||||
import { IconComponent } from '@backstage/core-plugin-api';
|
||||
import clsx from 'clsx';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown';
|
||||
import ArrowDropUpIcon from '@material-ui/icons/ArrowDropUp';
|
||||
import { ItemWithSubmenuContext } from './config';
|
||||
|
||||
const useStyles = makeStyles<BackstageTheme>(theme => ({
|
||||
item: {
|
||||
height: 48,
|
||||
width: '100%',
|
||||
'&:hover': {
|
||||
background: '#6f6f6f',
|
||||
color: theme.palette.navigation.selectedColor,
|
||||
},
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: theme.palette.navigation.color,
|
||||
padding: 20,
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
},
|
||||
itemContainer: {
|
||||
width: '100%',
|
||||
},
|
||||
selected: {
|
||||
background: '#6f6f6f',
|
||||
color: '#FFF',
|
||||
},
|
||||
label: {
|
||||
margin: 14,
|
||||
marginLeft: 7,
|
||||
fontSize: 14,
|
||||
},
|
||||
dropdownArrow: {
|
||||
position: 'absolute',
|
||||
right: 21,
|
||||
},
|
||||
dropdown: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'end',
|
||||
},
|
||||
dropdownItem: {
|
||||
width: '72%',
|
||||
margin: '10px 0 10px 0',
|
||||
color: theme.palette.navigation.color,
|
||||
},
|
||||
}));
|
||||
|
||||
type DropDownItem = {
|
||||
title: string;
|
||||
to: string;
|
||||
};
|
||||
type SubmenuItemProps = {
|
||||
title: string;
|
||||
to: string;
|
||||
hasDropDown?: boolean;
|
||||
icon: IconComponent;
|
||||
dropdownItems?: DropDownItem[];
|
||||
};
|
||||
|
||||
export const SubmenuItem = ({
|
||||
title,
|
||||
to,
|
||||
hasDropDown = false,
|
||||
icon: Icon,
|
||||
dropdownItems,
|
||||
}: SubmenuItemProps) => {
|
||||
const classes = useStyles();
|
||||
const { pathname: locationPathname } = useLocation();
|
||||
const { pathname: toPathname } = useResolvedPath(to);
|
||||
const { setIsHoveredOn } = useContext(ItemWithSubmenuContext);
|
||||
const closeSubmenu = () => {
|
||||
setIsHoveredOn(false);
|
||||
};
|
||||
|
||||
let isActive = locationPathname === toPathname;
|
||||
|
||||
const [showDropDown, setShowDropDown] = useState(false);
|
||||
const handleClickDropdown = () => {
|
||||
setShowDropDown(!showDropDown);
|
||||
};
|
||||
if (hasDropDown && dropdownItems !== undefined) {
|
||||
dropdownItems.some(item => {
|
||||
const resolvedPath = resolvePath(item.to);
|
||||
isActive = locationPathname === resolvedPath.pathname;
|
||||
return isActive;
|
||||
});
|
||||
return (
|
||||
<div className={classes.itemContainer}>
|
||||
<button
|
||||
onClick={handleClickDropdown}
|
||||
className={clsx(
|
||||
classes.item,
|
||||
isActive ? classes.selected : undefined,
|
||||
)}
|
||||
>
|
||||
<Icon fontSize="small" />
|
||||
<Typography variant="subtitle1" className={classes.label}>
|
||||
{title}
|
||||
</Typography>
|
||||
{showDropDown ? (
|
||||
<ArrowDropUpIcon className={classes.dropdownArrow} />
|
||||
) : (
|
||||
<ArrowDropDownIcon className={classes.dropdownArrow} />
|
||||
)}
|
||||
</button>
|
||||
{hasDropDown && showDropDown && (
|
||||
<div className={classes.dropdown}>
|
||||
{dropdownItems.map((object, key) => (
|
||||
<Link
|
||||
component={NavLink}
|
||||
to={object.to}
|
||||
underline="none"
|
||||
className={classes.dropdownItem}
|
||||
onClick={closeSubmenu}
|
||||
key={key}
|
||||
>
|
||||
{object.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.itemContainer}>
|
||||
<Link
|
||||
component={NavLink}
|
||||
to={to}
|
||||
underline="none"
|
||||
className={clsx(classes.item, isActive ? classes.selected : undefined)}
|
||||
onClick={closeSubmenu}
|
||||
>
|
||||
<Icon fontSize="small" />
|
||||
<Typography variant="subtitle1" className={classes.label}>
|
||||
{title}
|
||||
</Typography>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createContext } from 'react';
|
||||
import { createContext, Dispatch, SetStateAction } from 'react';
|
||||
|
||||
const drawerWidthClosed = 72;
|
||||
const iconPadding = 24;
|
||||
@@ -37,13 +37,44 @@ export const sidebarConfig = {
|
||||
userBadgeDiameter: drawerWidthClosed - userBadgePadding * 2,
|
||||
};
|
||||
|
||||
export const submenuConfig = {
|
||||
drawerWidthClosed: 0,
|
||||
drawerWidthOpen: 202,
|
||||
// As per NN/g's guidance on timing for exposing hidden content
|
||||
// See https://www.nngroup.com/articles/timing-exposing-content/
|
||||
defaultOpenDelayMs: 100,
|
||||
defaultCloseDelayMs: 0,
|
||||
defaultFadeDuration: 200,
|
||||
logoHeight: 32,
|
||||
iconContainerWidth: drawerWidthClosed,
|
||||
iconSize: drawerWidthClosed - iconPadding * 2,
|
||||
iconPadding,
|
||||
selectedIndicatorWidth: 3,
|
||||
userBadgePadding,
|
||||
userBadgeDiameter: drawerWidthClosed - userBadgePadding * 2,
|
||||
};
|
||||
|
||||
export const SIDEBAR_INTRO_LOCAL_STORAGE =
|
||||
'@backstage/core/sidebar-intro-dismissed';
|
||||
|
||||
export type SidebarContextType = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export const SidebarContext = createContext<SidebarContextType>({
|
||||
isOpen: false,
|
||||
setIsOpen: () => {},
|
||||
});
|
||||
|
||||
export type ItemWithSubmenuContextType = {
|
||||
isHoveredOn: boolean;
|
||||
setIsHoveredOn: Dispatch<SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export const ItemWithSubmenuContext = createContext<ItemWithSubmenuContextType>(
|
||||
{
|
||||
isHoveredOn: false,
|
||||
setIsHoveredOn: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
export const APIsIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M14 12L12 14L10 12L12 10L14 12ZM12 6L14.12 8.12L16.62 5.62L12 1L7.38 5.62L9.88 8.12L12 6ZM6 12L8.12 9.88L5.62 7.38L1 12L5.62 16.62L8.12 14.12L6 12ZM18 12L15.88 14.12L18.38 16.62L23 12L18.38 7.38L15.88 9.88L18 12ZM12 18L9.88 15.88L7.38 18.38L12 23L16.62 18.38L14.12 15.88L12 18Z"
|
||||
fill="#BDBDBD"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { SvgIcon } from '@material-ui/core';
|
||||
import React from 'react';
|
||||
|
||||
export const CatalogSidebarLogo = () => {
|
||||
return (
|
||||
<SvgIcon
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="21"
|
||||
fill="none"
|
||||
viewBox="0 0 20 21"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4 9.923l5.5-9 5.5 9H4zm7.43-2L9.5 4.763l-1.94 3.16h3.87zm3.57 4c-2.49 0-4.5 2.01-4.5 4.5s2.01 4.5 4.5 4.5 4.5-2.01 4.5-4.5-2.01-4.5-4.5-4.5zm-2.5 4.5a2.5 2.5 0 005 0 2.5 2.5 0 00-5 0zm-12 4h8v-8h-8v8zm6-6h-4v4h4v-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4 9.923l5.5-9 5.5 9H4zm7.43-2L9.5 4.763l-1.94 3.16h3.87zm3.57 4c-2.49 0-4.5 2.01-4.5 4.5s2.01 4.5 4.5 4.5 4.5-2.01 4.5-4.5-2.01-4.5-4.5-4.5zm-2.5 4.5a2.5 2.5 0 005 0 2.5 2.5 0 00-5 0zm-12 4h8v-8h-8v8zm6-6h-4v4h4v-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</SvgIcon>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { makeStyles } from '@material-ui/core';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
svg: {
|
||||
width: 'auto',
|
||||
height: 15,
|
||||
},
|
||||
path: {
|
||||
fill: '#7df3e1',
|
||||
},
|
||||
});
|
||||
const DoubleArrowRight = () => {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={classes.svg}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path
|
||||
d="M15.18 4.94408L14.236 4.00008L8.90266 9.33341L14.236 14.6667L15.18 13.7227L10.7907 9.33341L15.18 4.94408Z"
|
||||
fill="#BDBDBD"
|
||||
/>
|
||||
<path
|
||||
d="M8.90271 4.94408L7.95871 4.00008L2.62538 9.33341L7.95871 14.6667L8.90271 13.7227L4.51338 9.33341L8.90271 4.94408Z"
|
||||
fill="#BDBDBD"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default DoubleArrowRight;
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { makeStyles } from '@material-ui/core';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
svg: {
|
||||
width: 'auto',
|
||||
height: 15,
|
||||
},
|
||||
path: {
|
||||
fill: '#7df3e1',
|
||||
},
|
||||
});
|
||||
const DoubleArrowRight = () => {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={classes.svg}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path
|
||||
d="M0.82 11.0559L1.764 11.9999L7.09733 6.66658L1.764 1.33325L0.82 2.27725L5.20933 6.66658L0.82 11.0559Z"
|
||||
fill="#BDBDBD"
|
||||
/>
|
||||
<path
|
||||
d="M7.0973 11.0559L8.0413 11.9999L13.3746 6.66658L8.0413 1.33325L7.0973 2.27725L11.4866 6.66658L7.0973 11.0559Z"
|
||||
fill="#BDBDBD"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default DoubleArrowRight;
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
export const MiscIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M21.29 13.9L18 12L21.29 10.1C21.77 9.82 21.93 9.21 21.66 8.73L19.66 5.27C19.38 4.79 18.77 4.63 18.29 4.9L15 6.8V3C15 2.45 14.55 2 14 2H9.99999C9.44999 2 8.99999 2.45 8.99999 3V6.8L5.71 4.9C5.23 4.63 4.62 4.79 4.34 5.27L2.33999 8.73C2.05999 9.21 2.22999 9.82 2.70999 10.1L5.99999 12L2.70999 13.9C2.22999 14.18 2.06999 14.79 2.33999 15.27L4.34 18.73C4.62 19.21 5.23 19.37 5.71 19.1L8.99999 17.2V21C8.99999 21.55 9.44999 22 9.99999 22H14C14.55 22 15 21.55 15 21V17.2L18.29 19.1C18.77 19.38 19.38 19.21 19.66 18.73L21.66 15.27C21.94 14.79 21.77 14.18 21.29 13.9ZM18.43 16.87L13.75 14.17C13.42 13.97 13 14.21 13 14.6V20H11V14.6C11 14.22 10.58 13.97 10.25 14.17L5.57 16.87L4.57 15.14L9.24999 12.44C9.57999 12.25 9.57999 11.77 9.24999 11.57L4.57 8.87L5.57 7.14L10.25 9.84C10.58 10.03 11 9.79 11 9.4V4H13V9.4C13 9.78 13.42 10.03 13.75 9.83L18.43 7.13L19.43 8.86L14.75 11.56C14.42 11.75 14.42 12.23 14.75 12.43L19.43 15.13L18.43 16.87Z"
|
||||
fill="#BDBDBD"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
export const ServicesIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M16.59 6.59L14.18 5.5L16.59 4.41L17.68 2L18.77 4.41L21.18 5.5L18.77 6.59L17.68 9L16.59 6.59ZM20.46 12.72L19.68 11L18.9 12.72L17.18 13.5L18.9 14.28L19.68 16L20.46 14.28L22.18 13.5L20.46 12.72ZM15.42 14.37L17.36 15.84L14.86 20.17L12.62 19.23C12.42 19.36 12.2 19.49 11.98 19.6L11.68 22H6.68L6.38 19.59C6.16 19.48 5.95 19.36 5.74 19.22L3.5 20.16L1 15.83L2.94 14.36C2.93 14.25 2.93 14.12 2.93 14C2.93 13.88 2.93 13.75 2.94 13.63L1 12.16L3.5 7.83L5.74 8.77C5.94 8.64 6.16 8.51 6.38 8.4L6.68 6H11.68L11.98 8.41C12.2 8.52 12.41 8.64 12.62 8.78L14.86 7.84L17.36 12.17L15.42 13.64C15.43 13.76 15.43 13.88 15.43 14.01C15.43 14.14 15.43 14.25 15.42 14.37ZM12.18 14C12.18 12.34 10.84 11 9.18 11C7.52 11 6.18 12.34 6.18 14C6.18 15.66 7.52 17 9.18 17C10.84 17 12.18 15.66 12.18 14Z"
|
||||
fill="#BDBDBD"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -14,7 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { Sidebar } from './Bar';
|
||||
export { Sidebar, SidebarExpandButton } from './Bar';
|
||||
export { SubmenuItem } from './SubmenuItem';
|
||||
export type { SidebarClassKey } from './Bar';
|
||||
export { SidebarPage, SidebarPinStateContext } from './Page';
|
||||
export type { SidebarPinStateContextType, SidebarPageClassKey } from './Page';
|
||||
|
||||
@@ -70,6 +70,15 @@ export const lightTheme = createTheme({
|
||||
indicator: '#9BF0E1',
|
||||
color: '#b5b5b5',
|
||||
selectedColor: '#FFF',
|
||||
navItem: {
|
||||
hoverBackground: '#404040',
|
||||
},
|
||||
submenu: {
|
||||
background: '#404040',
|
||||
indicator: '#9BF0E1',
|
||||
color: '#b5b5b5',
|
||||
selectedColor: '#FFF',
|
||||
},
|
||||
},
|
||||
pinSidebarButton: {
|
||||
icon: '#181818',
|
||||
@@ -139,6 +148,15 @@ export const darkTheme = createTheme({
|
||||
indicator: '#9BF0E1',
|
||||
color: '#b5b5b5',
|
||||
selectedColor: '#FFF',
|
||||
navItem: {
|
||||
hoverBackground: '#404040',
|
||||
},
|
||||
submenu: {
|
||||
background: '#404040',
|
||||
indicator: '#9BF0E1',
|
||||
color: '#b5b5b5',
|
||||
selectedColor: '#FFF',
|
||||
},
|
||||
},
|
||||
pinSidebarButton: {
|
||||
icon: '#404040',
|
||||
|
||||
@@ -48,6 +48,15 @@ type PaletteAdditions = {
|
||||
indicator: string;
|
||||
color: string;
|
||||
selectedColor: string;
|
||||
navItem: {
|
||||
hoverBackground: string;
|
||||
};
|
||||
submenu: {
|
||||
background: string;
|
||||
indicator: string;
|
||||
color: string;
|
||||
selectedColor: string;
|
||||
};
|
||||
};
|
||||
tabbar: {
|
||||
indicator: string;
|
||||
|
||||
Reference in New Issue
Block a user