Merge pull request #7521 from hiba-aldalaty/scalable-sidebar-new-implementation

New Scalable Side Nav design Implementation
This commit is contained in:
Ben Lambert
2021-12-02 13:08:35 +01:00
committed by GitHub
19 changed files with 959 additions and 29 deletions
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/core-components': patch
'@backstage/theme': patch
---
Added `<SidebarSubmenu>` and `<SidebarSubmenuItem>` to enable building better sidebars. You can check out the storybook for more inspiration and how to get started.
Added two new theme props for styling the sidebar too, `navItem.hoverBackground` and `submenu.background`.
+35
View File
@@ -832,6 +832,7 @@ export const SidebarContext: Context<SidebarContextType>;
// @public (undocumented)
export type SidebarContextType = {
isOpen: boolean;
setOpen: (open: boolean) => void;
};
// Warning: (ae-missing-release-tag) "SidebarDivider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@@ -1103,6 +1104,9 @@ export const SidebarDivider: React_2.ComponentType<
}
>;
// @public
export const SidebarExpandButton: () => JSX.Element | null;
// Warning: (ae-missing-release-tag) "SidebarIntro" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -1977,6 +1981,37 @@ export const SidebarSpacer: React_2.ComponentType<
}
>;
// @public
export const SidebarSubmenu: ({
title,
children,
}: PropsWithChildren<SidebarSubmenuProps>) => JSX.Element;
// @public
export const SidebarSubmenuItem: (
props: SidebarSubmenuItemProps,
) => JSX.Element;
// @public
export type SidebarSubmenuItemDropdownItem = {
title: string;
to: string;
};
// @public
export type SidebarSubmenuItemProps = {
title: string;
to: string;
icon: IconComponent;
dropdownItems?: SidebarSubmenuItemDropdownItem[];
};
// @public
export type SidebarSubmenuProps = {
title?: string;
children: ReactNode;
};
// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "SignInPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -0,0 +1,108 @@
/*
* 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 { renderInTestApp } from '@backstage/test-utils';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import BuildRoundedIcon from '@material-ui/icons/BuildRounded';
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
import MenuBookIcon from '@material-ui/icons/MenuBook';
import AcUnitIcon from '@material-ui/icons/AcUnit';
import { Sidebar, SidebarExpandButton } from './Bar';
import { SidebarItem, SidebarSearchField } from './Items';
import { SidebarSubmenuItem } from './SidebarSubmenuItem';
import { SidebarSubmenu } from './SidebarSubmenu';
import { SidebarPinStateContext } from '.';
async function renderScalableSidebar() {
await renderInTestApp(
<SidebarPinStateContext.Provider
value={{ isPinned: false, toggleSidebarPinState: () => {} }}
>
<Sidebar disableExpandOnHover>
<SidebarSearchField onSearch={() => {}} to="/search" />
<SidebarItem icon={MenuBookIcon} onClick={() => {}} text="Catalog">
<SidebarSubmenu title="Catalog">
<SidebarSubmenuItem title="Tools" to="/1" icon={BuildRoundedIcon} />
<SidebarSubmenuItem
title="Misc"
to="/6"
icon={AcUnitIcon}
dropdownItems={[
{
title: 'dropdown item 1',
to: '/dropdownitemlink',
},
{
title: 'dropdown item 2',
to: '/dropdownitemlink2',
},
]}
/>
</SidebarSubmenu>
</SidebarItem>
<SidebarItem icon={CreateComponentIcon} to="create" text="Create..." />
<SidebarExpandButton />
</Sidebar>
</SidebarPinStateContext.Provider>,
);
}
describe('Sidebar', () => {
beforeEach(async () => {
await renderScalableSidebar();
});
describe('Click to Expand', () => {
it('Sidebar should show expanded items when expand button is clicked', async () => {
userEvent.click(screen.getByTestId('sidebar-expand-button'));
expect(await screen.findByText('Create...')).toBeInTheDocument();
});
it('Sidebar should not show expanded items when hovered on', async () => {
userEvent.hover(screen.getByTestId('sidebar-root'));
expect(screen.queryByText('Create...')).not.toBeInTheDocument();
});
});
describe('Submenu Items', () => {
it('Extended sidebar with submenu content hidden by default', async () => {
expect(screen.queryByText('Tools')).not.toBeInTheDocument();
expect(screen.queryByText('Misc')).not.toBeInTheDocument();
});
it('Extended sidebar with submenu content visible when hover over submenu items', async () => {
userEvent.hover(screen.getByTestId('item-with-submenu'));
expect(await screen.findByText('Tools')).toBeInTheDocument();
expect(await screen.findByText('Misc')).toBeInTheDocument();
});
it('Multicategory item in submenu shows drop down on click', async () => {
userEvent.hover(screen.getByTestId('item-with-submenu'));
userEvent.click(screen.getByText('Misc'));
expect(screen.getByText('dropdown item 1')).toBeInTheDocument();
expect(screen.getByText('dropdown item 2')).toBeInTheDocument();
});
it('Dropdown item in submenu renders a link when `to` value is provided', async () => {
userEvent.hover(screen.getByTestId('item-with-submenu'));
userEvent.click(screen.getByText('Misc'));
expect(screen.getByText('dropdown item 1').closest('a')).toHaveAttribute(
'href',
'/dropdownitemlink',
);
});
});
});
@@ -17,10 +17,12 @@
import { makeStyles } from '@material-ui/core/styles';
import useMediaQuery from '@material-ui/core/useMediaQuery';
import clsx from 'clsx';
import React, { useRef, useState, useContext, PropsWithChildren } from 'react';
import React, { useState, useContext, PropsWithChildren, useRef } 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';
@@ -46,7 +48,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,
@@ -58,6 +59,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', {
@@ -78,10 +92,12 @@ enum State {
type Props = {
openDelayMs?: number;
closeDelayMs?: number;
disableExpandOnHover?: boolean;
};
export function Sidebar(props: PropsWithChildren<Props>) {
const {
disableExpandOnHover = false,
openDelayMs = sidebarConfig.defaultOpenDelayMs,
closeDelayMs = sidebarConfig.defaultCloseDelayMs,
children,
@@ -132,18 +148,27 @@ export function Sidebar(props: PropsWithChildren<Props>) {
const isOpen = (state === State.Open && !isSmallScreen) || isPinned;
const setOpen = (open: boolean) => {
if (open) {
handleOpen();
} else {
handleClose();
}
};
return (
<div
className={classes.root}
onMouseEnter={handleOpen}
onFocus={handleOpen}
onMouseLeave={handleClose}
onBlur={handleClose}
data-testid="sidebar-root"
onMouseEnter={disableExpandOnHover ? () => {} : handleOpen}
onFocus={disableExpandOnHover ? () => {} : handleOpen}
onMouseLeave={disableExpandOnHover ? () => {} : handleClose}
onBlur={disableExpandOnHover ? () => {} : handleClose}
>
<SidebarContext.Provider
value={{
isOpen,
setOpen,
}}
>
<div
@@ -157,3 +182,39 @@ export function Sidebar(props: PropsWithChildren<Props>) {
</div>
);
}
/**
* A button which allows you to expand the sidebar when clicked.
* Use optionally to replace sidebar's expand-on-hover feature with expand-on-click.
*
* @public
*/
export const SidebarExpandButton = () => {
const classes = useStyles();
const { isOpen, setOpen } = useContext(SidebarContext);
const { isPinned } = useContext(SidebarPinStateContext);
const isSmallScreen = useMediaQuery<BackstageTheme>(theme =>
theme.breakpoints.down('md'),
);
const handleClick = () => {
setOpen(!isOpen);
};
if (isPinned || isSmallScreen) {
return null;
}
return (
<button
onClick={handleClick}
className={classes.expandButton}
aria-label="Expand Sidebar"
data-testid="sidebar-expand-button"
>
<div className={classes.arrows}>
{isOpen ? <DoubleArrowLeft /> : <DoubleArrowRight />}
</div>
</button>
);
};
@@ -20,7 +20,7 @@ import { createEvent, fireEvent, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import HomeIcon from '@material-ui/icons/Home';
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
import { Sidebar } from './Bar';
import { Sidebar, SidebarExpandButton } from './Bar';
import { SidebarItem, SidebarSearchField } from './Items';
import { renderHook } from '@testing-library/react-hooks';
import { hexToRgb, makeStyles } from '@material-ui/core/styles';
@@ -44,6 +44,7 @@ async function renderSidebar() {
text="Create..."
className={result.current.spotlight}
/>
<SidebarExpandButton />
</Sidebar>,
);
userEvent.hover(screen.getByTestId('sidebar-root'));
@@ -14,18 +14,21 @@
* limitations under the License.
*/
import { IconComponent } from '@backstage/core-plugin-api';
import { IconComponent, useElementFilter } from '@backstage/core-plugin-api';
import { BackstageTheme } from '@backstage/theme';
import { makeStyles, styled, Theme } from '@material-ui/core/styles';
import Badge from '@material-ui/core/Badge';
import TextField from '@material-ui/core/TextField';
import Typography from '@material-ui/core/Typography';
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,
@@ -33,10 +36,16 @@ import React, {
import {
Link,
NavLinkProps,
resolvePath,
useLocation,
useResolvedPath,
} from 'react-router-dom';
import { sidebarConfig, SidebarContext } from './config';
import {
sidebarConfig,
SidebarContext,
SidebarItemWithSubmenuContext,
} from './config';
import { SidebarSubmenu } from './SidebarSubmenu';
export type SidebarItemClassKey =
| 'root'
@@ -85,6 +94,16 @@ const useStyles = makeStyles<BackstageTheme>(
open: {
width: drawerWidthOpen,
},
highlightable: {
'&:hover': {
background:
theme.palette.navigation.navItem?.hoverBackground ?? '#404040',
},
},
highlighted: {
background:
theme.palette.navigation.navItem?.hoverBackground ?? '#404040',
},
label: {
// XXX (@koroeskohr): I can't seem to achieve the desired font-weight from the designs
fontWeight: 'bold',
@@ -123,13 +142,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,
@@ -140,16 +170,124 @@ const useStyles = makeStyles<BackstageTheme>(
{ name: 'BackstageSidebarItem' },
);
function isSidebarItemWithSubmenuActive(
submenu: ReactNode,
locationPathname: string,
) {
// Item is active if any of submenu items have active paths
const toPathnames: string[] = [];
let isActive = false;
let submenuItems: ReactNode;
Children.forEach(submenu, element => {
if (!React.isValidElement(element)) return;
submenuItems = element.props.children;
});
Children.forEach(submenuItems, element => {
if (!React.isValidElement(element)) return;
if (element.props.dropdownItems) {
element.props.dropdownItems.map((item: { to: string }) =>
toPathnames.push(item.to),
);
} else if (element.props.to) {
toPathnames.push(element.props.to);
}
});
isActive = toPathnames.some(to => {
const toPathname = resolvePath(to);
return locationPathname === toPathname.pathname;
});
return isActive;
}
const SidebarItemWithSubmenu = ({
text,
hasNotifications = false,
icon: Icon,
children,
}: PropsWithChildren<SidebarItemWithSubmenuProps>) => {
const classes = useStyles();
const [isHoveredOn, setIsHoveredOn] = useState(false);
const { pathname: locationPathname } = useLocation();
const isActive = isSidebarItemWithSubmenuActive(children, locationPathname);
const handleMouseEnter = () => {
setIsHoveredOn(true);
};
const handleMouseLeave = () => {
setIsHoveredOn(false);
};
const { isOpen } = useContext(SidebarContext);
const itemIcon = (
<Badge
color="secondary"
variant="dot"
overlap="circular"
className={isOpen ? '' : classes.closedItemIcon}
invisible={!hasNotifications}
>
<Icon fontSize="small" />
</Badge>
);
const openContent = (
<>
<div data-testid="login-button" className={classes.iconContainer}>
{itemIcon}
</div>
{text && (
<Typography variant="subtitle2" className={classes.text}>
{text}
</Typography>
)}
<div className={classes.secondaryAction}>{}</div>
</>
);
const closedContent = itemIcon;
return (
<SidebarItemWithSubmenuContext.Provider
value={{
isHoveredOn,
setIsHoveredOn,
}}
>
<div
onMouseLeave={handleMouseLeave}
className={clsx(isHoveredOn && classes.highlighted)}
>
<div
onMouseEnter={handleMouseEnter}
data-testid="item-with-submenu"
className={clsx(
classes.root,
isOpen ? classes.open : classes.closed,
isActive && classes.selected,
classes.highlightable,
isHoveredOn && classes.highlighted,
)}
>
{isOpen ? openContent : closedContent}
{!isHoveredOn && (
<ArrowRightIcon fontSize="small" className={classes.submenuArrow} />
)}
</div>
{isHoveredOn && children}
</div>
</SidebarItemWithSubmenuContext.Provider>
);
};
type SidebarItemBaseProps = {
icon: IconComponent;
text?: string;
hasNotifications?: boolean;
children?: ReactNode;
disableHighlight?: boolean;
className?: string;
};
type SidebarItemButtonProps = SidebarItemBaseProps & {
onClick: (ev: React.MouseEvent) => void;
children?: ReactNode;
};
type SidebarItemLinkProps = SidebarItemBaseProps & {
@@ -157,7 +295,21 @@ type SidebarItemLinkProps = SidebarItemBaseProps & {
onClick?: (ev: React.MouseEvent) => void;
} & NavLinkProps;
type SidebarItemProps = SidebarItemButtonProps | SidebarItemLinkProps;
type SidebarItemWithSubmenuProps = SidebarItemBaseProps & {
to?: string;
onClick?: (ev: React.MouseEvent) => void;
children: ReactNode;
};
/**
* SidebarItem with 'to' property will be a clickable link.
* SidebarItem with 'onClick' property and without 'to' property will be a clickable button.
* SidebarItem which wraps a SidebarSubmenu will be a clickable button which opens a submenu.
*/
type SidebarItemProps =
| SidebarItemLinkProps
| SidebarItemButtonProps
| SidebarItemWithSubmenuProps;
function isButtonItem(
props: SidebarItemProps,
@@ -218,6 +370,7 @@ export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
icon: Icon,
text,
hasNotifications = false,
disableHighlight = false,
onClick,
children,
className,
@@ -235,6 +388,7 @@ export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
variant="dot"
overlap="circular"
invisible={!hasNotifications}
className={clsx({ [classes.closedItemIcon]: !isOpen })}
>
<Icon fontSize="small" />
</Badge>
@@ -248,7 +402,7 @@ export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
{itemIcon}
</div>
{text && (
<Typography variant="subtitle2" className={classes.label}>
<Typography variant="subtitle2" className={classes.text}>
{text}
</Typography>
)}
@@ -265,9 +419,43 @@ export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
classes.root,
isOpen ? classes.open : classes.closed,
isButtonItem(props) && classes.buttonItem,
{ [classes.highlightable]: !disableHighlight },
),
};
let hasSubmenu = false;
let submenu: ReactNode;
const componentType = (
<SidebarSubmenu>
<></>
</SidebarSubmenu>
).type;
// Filter children for SidebarSubmenu components
const submenus = useElementFilter(children, elements =>
elements.getElements().filter(child => child.type === componentType),
);
// Error thrown if more than one SidebarSubmenu in a SidebarItem
if (submenus.length > 1) {
throw new Error(
'Cannot render more than one SidebarSubmenu inside a SidebarItem',
);
} else if (submenus.length === 1) {
hasSubmenu = true;
submenu = submenus[0];
}
if (hasSubmenu) {
return (
<SidebarItemWithSubmenu
text={text}
icon={Icon}
hasNotifications={hasNotifications}
>
{submenu}
</SidebarItemWithSubmenu>
);
}
if (isButtonItem(props)) {
return (
<button aria-label={text} {...childProps} ref={ref}>
@@ -280,7 +468,7 @@ export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
<WorkaroundNavLink
{...childProps}
activeClassName={classes.selected}
to={props.to}
to={props.to ? props.to : ''}
ref={ref}
aria-label={text ? text : props.to}
{...navLinkProps}
@@ -331,7 +519,12 @@ export function SidebarSearchField(props: SidebarSearchFieldProps) {
return (
<div className={classes.searchRoot}>
<SidebarItem icon={Icon} to={props.to} onClick={handleItemClick}>
<SidebarItem
icon={Icon}
to={props.to}
onClick={handleItemClick}
disableHighlight
>
<TextField
placeholder="Search"
value={input}
@@ -16,16 +16,27 @@
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,
SidebarPage,
SidebarSearchField,
SidebarSpace,
} from '.';
import { SidebarSubmenuItem } from './SidebarSubmenuItem';
import MenuBookIcon from '@material-ui/icons/MenuBook';
import CloudQueueIcon from '@material-ui/icons/CloudQueue';
import SettingsApplications from '@material-ui/icons/SettingsApplications';
import AcUnitIcon from '@material-ui/icons/AcUnit';
import { SidebarSubmenu } from './SidebarSubmenu';
export default {
title: 'Layout/Sidebar',
@@ -43,14 +54,62 @@ const handleSearch = (input: string) => {
};
export const SampleSidebar = () => (
<Sidebar>
<SidebarSearchField onSearch={handleSearch} to="/search" />
<SidebarDivider />
<SidebarItem icon={HomeOutlinedIcon} to="#" text="Home" />
<SidebarItem icon={HomeOutlinedIcon} to="#" text="Plugins" />
<SidebarItem icon={AddCircleOutlineIcon} to="#" text="Create..." />
<SidebarDivider />
<SidebarIntro />
<SidebarSpace />
</Sidebar>
<SidebarPage>
<Sidebar>
<SidebarSearchField onSearch={handleSearch} to="/search" />
<SidebarDivider />
<SidebarItem icon={HomeOutlinedIcon} to="#" text="Plugins" />
<SidebarItem icon={AddCircleOutlineIcon} to="#" text="Create..." />
<SidebarDivider />
<SidebarIntro />
<SidebarSpace />
</Sidebar>
</SidebarPage>
);
export const SampleScalableSidebar = () => (
<SidebarPage>
<Sidebar disableExpandOnHover>
<SidebarSearchField onSearch={handleSearch} to="/search" />
<SidebarDivider />
<SidebarItem icon={MenuBookIcon} text="Catalog">
<SidebarSubmenu title="Catalog">
<SidebarSubmenuItem title="Tools" to="/1" icon={BuildRoundedIcon} />
<SidebarSubmenuItem title="APIs" to="/2" icon={CloudQueueIcon} />
<SidebarSubmenuItem
title="Services"
to="/3"
icon={SettingsApplications}
/>
<SidebarSubmenuItem
title="Libraries"
to="/4"
icon={LibraryBooksOutlinedIcon}
/>
<SidebarSubmenuItem title="Websites" to="/5" icon={WebOutlinedIcon} />
<SidebarSubmenuItem
title="Misc"
to="/6"
icon={AcUnitIcon}
dropdownItems={[
{
title: 'Lorem Ipsum',
to: '/7',
},
{
title: 'Lorem Ipsum',
to: '/8',
},
]}
/>
</SidebarSubmenu>
</SidebarItem>
<SidebarItem icon={HomeOutlinedIcon} to="#" text="Plugins" />
<SidebarItem icon={AddCircleOutlineIcon} to="#" text="Create..." />
<SidebarDivider />
<SidebarIntro />
<SidebarSpace />
<SidebarExpandButton />
</Sidebar>
</SidebarPage>
);
@@ -0,0 +1,110 @@
/*
* 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 } from '@material-ui/core/styles';
import Typography from '@material-ui/core/Typography';
import clsx from 'clsx';
import React, { PropsWithChildren, ReactNode, useContext } from 'react';
import {
SidebarItemWithSubmenuContext,
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 ?? '#404040',
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,
},
}));
/**
* Holds a title for text Header of a sidebar submenu and children
* components to be rendered inside SidebarSubmenu
*
* @public
*/
export type SidebarSubmenuProps = {
title?: string;
children: ReactNode;
};
/**
* Used inside SidebarItem to display an expandable Submenu
*
* @public
*/
export const SidebarSubmenu = ({
title,
children,
}: PropsWithChildren<SidebarSubmenuProps>) => {
const { isOpen } = useContext(SidebarContext);
const left = isOpen
? sidebarConfig.drawerWidthOpen
: sidebarConfig.drawerWidthClosed;
const props = { left: left };
const classes = useStyles(props)();
const { isHoveredOn } = useContext(SidebarItemWithSubmenuContext);
return (
<div
className={clsx(classes.drawer, {
[classes.drawerOpen]: isHoveredOn,
})}
>
<Typography variant="h5" className={classes.title}>
{title}
</Typography>
{children}
</div>
);
};
@@ -0,0 +1,194 @@
/*
* 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 { makeStyles } from '@material-ui/core/styles';
import Typography from '@material-ui/core/Typography';
import Link from '@material-ui/core/Link';
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 { SidebarItemWithSubmenuContext } 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: '100%',
padding: '10px 0 10px 0',
},
textContent: {
color: theme.palette.navigation.color,
display: 'flex',
justifyContent: 'center',
fontSize: '14px',
},
}));
/**
* Clickable item displayed when submenu item is clicked.
* title: Text content of item
* to: Path to navigate to when item is clicked
*
* @public
*/
export type SidebarSubmenuItemDropdownItem = {
title: string;
to: string;
};
/**
* Holds submenu item content.
*
* title: Text content of submenu item
* to: Path to navigate to when item is clicked
* icon: Icon displayed on the left of text content
* dropdownItems: Optional array of dropdown items displayed when submenu item is clicked.
*
* @public
*/
export type SidebarSubmenuItemProps = {
title: string;
to: string;
icon: IconComponent;
dropdownItems?: SidebarSubmenuItemDropdownItem[];
};
/**
* Item used inside a submenu within the sidebar.
*
* @public
*/
export const SidebarSubmenuItem = (props: SidebarSubmenuItemProps) => {
const { title, to, icon: Icon, dropdownItems } = props;
const classes = useStyles();
const { pathname: locationPathname } = useLocation();
const { pathname: toPathname } = useResolvedPath(to);
const { setIsHoveredOn } = useContext(SidebarItemWithSubmenuContext);
const closeSubmenu = () => {
setIsHoveredOn(false);
};
let isActive = locationPathname === toPathname;
const [showDropDown, setShowDropDown] = useState(false);
const handleClickDropdown = () => {
setShowDropDown(!showDropDown);
};
if (dropdownItems !== undefined) {
dropdownItems.some(item => {
const resolvedPath = resolvePath(item.to);
isActive = locationPathname === resolvedPath.pathname;
});
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>
{dropdownItems && showDropDown && (
<div className={classes.dropdown}>
{dropdownItems.map((object, key) => (
<Link
component={NavLink}
to={object.to}
underline="none"
className={classes.dropdownItem}
onClick={closeSubmenu}
key={key}
>
<Typography className={classes.textContent}>
{object.title}
</Typography>
</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,43 @@ 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;
setOpen: (open: boolean) => void;
};
export const SidebarContext = createContext<SidebarContextType>({
isOpen: false,
setOpen: _open => {},
});
export type SidebarItemWithSubmenuContextType = {
isHoveredOn: boolean;
setIsHoveredOn: Dispatch<SetStateAction<boolean>>;
};
export const SidebarItemWithSubmenuContext =
createContext<SidebarItemWithSubmenuContextType>({
isHoveredOn: false,
setIsHoveredOn: () => {},
});
@@ -0,0 +1,48 @@
/*
* 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/styles';
import ArrowBackIosIcon from '@material-ui/icons/ArrowBackIos';
const useStyles = makeStyles({
iconContainer: {
display: 'flex',
position: 'relative',
width: '100%',
},
arrow1: {
right: '6px',
position: 'absolute',
},
});
const DoubleArrowLeft = () => {
const classes = useStyles();
return (
<div className={classes.iconContainer}>
<div className={classes.arrow1}>
<ArrowBackIosIcon style={{ fontSize: '12px' }} />
</div>
<div>
<ArrowBackIosIcon style={{ fontSize: '12px' }} />
</div>
</div>
);
};
export default DoubleArrowLeft;
@@ -0,0 +1,48 @@
/*
* 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/styles';
import ArrowForwardIosIcon from '@material-ui/icons/ArrowForwardIos';
const useStyles = makeStyles({
iconContainer: {
display: 'flex',
position: 'relative',
width: '100%',
},
arrow1: {
right: '6px',
position: 'absolute',
},
});
const DoubleArrowRight = () => {
const classes = useStyles();
return (
<div className={classes.iconContainer}>
<div className={classes.arrow1}>
<ArrowForwardIosIcon style={{ fontSize: '12px' }} />
</div>
<div>
<ArrowForwardIosIcon style={{ fontSize: '12px' }} />
</div>
</div>
);
};
export default DoubleArrowRight;
@@ -14,7 +14,14 @@
* limitations under the License.
*/
export { Sidebar } from './Bar';
export { Sidebar, SidebarExpandButton } from './Bar';
export { SidebarSubmenuItem } from './SidebarSubmenuItem';
export { SidebarSubmenu } from './SidebarSubmenu';
export type { SidebarSubmenuProps } from './SidebarSubmenu';
export type {
SidebarSubmenuItemProps,
SidebarSubmenuItemDropdownItem,
} from './SidebarSubmenuItem';
export type { SidebarClassKey } from './Bar';
export { SidebarPage, SidebarPinStateContext } from './Page';
export type { SidebarPinStateContextType, SidebarPageClassKey } from './Page';
+4
View File
@@ -10,6 +10,7 @@ import {
OktaAuth,
Auth0Auth,
ConfigReader,
LocalStorageFeatureFlags,
} from '@backstage/core-app-api';
import {
@@ -24,9 +25,11 @@ import {
oktaAuthApiRef,
auth0AuthApiRef,
configApiRef,
featureFlagsApiRef,
} from '@backstage/core-plugin-api';
const configApi = new ConfigReader({});
const featureFlagsApi = new LocalStorageFeatureFlags();
const alertApi = new AlertApiForwarder();
const errorApi = new ErrorAlerter(alertApi, new ErrorApiForwarder());
const identityApi = {
@@ -69,6 +72,7 @@ const oauth2Api = OAuth2.create({
export const apis = [
[configApiRef, configApi],
[featureFlagsApiRef, featureFlagsApi],
[alertApiRef, alertApi],
[errorApiRef, errorApi],
[identityApiRef, identityApi],
+6
View File
@@ -41,6 +41,12 @@ export type BackstagePaletteAdditions = {
indicator: string;
color: string;
selectedColor: string;
navItem?: {
hoverBackground: string;
};
submenu?: {
background: string;
};
};
tabbar: {
indicator: string;
+12
View File
@@ -76,6 +76,12 @@ export const lightTheme = createTheme({
indicator: '#9BF0E1',
color: '#b5b5b5',
selectedColor: '#FFF',
navItem: {
hoverBackground: '#404040',
},
submenu: {
background: '#404040',
},
},
pinSidebarButton: {
icon: '#181818',
@@ -151,6 +157,12 @@ export const darkTheme = createTheme({
indicator: '#9BF0E1',
color: '#b5b5b5',
selectedColor: '#FFF',
navItem: {
hoverBackground: '#404040',
},
submenu: {
background: '#404040',
},
},
pinSidebarButton: {
icon: '#404040',
+6
View File
@@ -53,6 +53,12 @@ export type BackstagePaletteAdditions = {
indicator: string;
color: string;
selectedColor: string;
navItem?: {
hoverBackground: string;
};
submenu?: {
background: string;
};
};
tabbar: {
indicator: string;
+1 -1
View File
@@ -37,7 +37,7 @@ describe('ShortcutItem', () => {
it('displays the shortcut', async () => {
await renderInTestApp(
<SidebarContext.Provider value={{ isOpen: true }}>
<SidebarContext.Provider value={{ isOpen: true, setOpen: _open => {} }}>
<ShortcutItem api={api} shortcut={shortcut} />
</SidebarContext.Provider>,
);
+1 -1
View File
@@ -29,7 +29,7 @@ import { SidebarContext } from '@backstage/core-components';
describe('Shortcuts', () => {
it('displays an add button', async () => {
await renderInTestApp(
<SidebarContext.Provider value={{ isOpen: true }}>
<SidebarContext.Provider value={{ isOpen: true, setOpen: _open => {} }}>
<TestApiProvider
apis={[
[