Improve side navigation (#589)

* Implement sidebar items

* Add intro component

* Add user badge component

* Add Sidebar simple story component

Co-authored-by: Victor Viale <victor.viale@besedo.com>
Co-authored-by: Stefan Ålund <alund@spotify.com>
This commit is contained in:
Victor Viale
2020-05-18 11:56:11 +02:00
committed by GitHub
parent 7db02e5e58
commit 5da07d5bbf
9 changed files with 516 additions and 67 deletions
+20 -8
View File
@@ -19,27 +19,30 @@ import PropTypes from 'prop-types';
import { Link, makeStyles, Typography } from '@material-ui/core';
import HomeIcon from '@material-ui/icons/Home';
import ExploreIcon from '@material-ui/icons/Explore';
import AccountCircle from '@material-ui/icons/AccountCircle';
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
import AccountTreeIcon from '@material-ui/icons/AccountTree';
import {
Sidebar,
SidebarPage,
sidebarConfig,
SidebarContext,
SidebarItem,
SidebarSpacer,
SidebarDivider,
SidebarSearchField,
SidebarSpace,
SidebarUserBadge,
SidebarThemeToggle,
} from '@backstage/core';
const useSidebarLogoStyles = makeStyles({
root: {
height: sidebarConfig.drawerWidthClosed,
width: sidebarConfig.drawerWidthClosed,
height: 3 * sidebarConfig.logoHeight,
display: 'flex',
flexFlow: 'row nowrap',
alignItems: 'center',
marginBottom: -14,
},
logoContainer: {
width: sidebarConfig.drawerWidthClosed,
@@ -48,9 +51,9 @@ const useSidebarLogoStyles = makeStyles({
justifyContent: 'center',
},
title: {
fontSize: 24,
fontSize: sidebarConfig.logoHeight,
fontWeight: 'bold',
marginLeft: 22,
marginLeft: 20,
whiteSpace: 'nowrap',
color: '#fff',
},
@@ -61,7 +64,7 @@ const useSidebarLogoStyles = makeStyles({
const SidebarLogo: FC<{}> = () => {
const classes = useSidebarLogoStyles();
const isOpen = useContext(SidebarContext);
const { isOpen } = useContext(SidebarContext);
return (
<div className={classes.root}>
@@ -75,21 +78,30 @@ const SidebarLogo: FC<{}> = () => {
);
};
const handleSearch = (query: string): void => {
// XXX (@koroeskohr): for testing purposes
// eslint-disable-next-line no-console
console.log(query);
};
const Root: FC<{}> = ({ children }) => (
<SidebarPage>
<Sidebar>
<SidebarLogo />
<SidebarSpacer />
<SidebarSearchField onSearch={handleSearch} />
<SidebarDivider />
{/* Global nav, not org-specific */}
<SidebarItem icon={HomeIcon} to="/" text="Home" />
<SidebarItem icon={ExploreIcon} to="/explore" text="Explore" />
<SidebarItem icon={CreateComponentIcon} to="/create" text="Create..." />
{/* End global nav */}
<SidebarDivider />
<SidebarItem icon={AccountTreeIcon} to="/catalog" text="Catalog" />
<SidebarItem icon={AccountCircle} to="/login" text="Login" />
<SidebarDivider />
<SidebarSpace />
<SidebarDivider />
<SidebarThemeToggle />
<SidebarUserBadge />
</Sidebar>
{children}
</SidebarPage>
+2 -2
View File
@@ -20,7 +20,7 @@ import React, { FC, useRef, useState } from 'react';
import { sidebarConfig, SidebarContext } from './config';
import { BackstageTheme } from '@backstage/theme';
const useStyles = makeStyles<BackstageTheme>(theme => ({
const useStyles = makeStyles<BackstageTheme>((theme) => ({
root: {
zIndex: 1000,
position: 'relative',
@@ -115,7 +115,7 @@ export const Sidebar: FC<Props> = ({
onBlur={handleClose}
data-testid="sidebar-root"
>
<SidebarContext.Provider value={state === State.Open}>
<SidebarContext.Provider value={{ isOpen: state === State.Open }}>
<div
className={clsx(classes.drawer, {
[classes.drawerPeek]: state === State.Peek,
+170
View File
@@ -0,0 +1,170 @@
/*
* Copyright 2020 Spotify AB
*
* 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, { FC, useContext, useState } from 'react';
import { useLocalStorage } from 'react-use';
import { Link, Typography, makeStyles, Collapse } from '@material-ui/core';
import CloseIcon from '@material-ui/icons/Close';
import { BackstageTheme } from '@backstage/theme';
import {
SIDEBAR_INTRO_LOCAL_STORAGE,
SidebarContext,
sidebarConfig,
} from './config';
import { SidebarDivider } from './Items';
const useStyles = makeStyles<BackstageTheme>(theme => ({
introCard: {
color: '#b5b5b5',
// XXX (@koroeskohr): should I be using a Mui theme variable?
fontSize: 12,
width: sidebarConfig.drawerWidthOpen,
marginTop: 18,
marginBottom: 12,
paddingLeft: sidebarConfig.iconPadding,
paddingRight: sidebarConfig.iconPadding,
},
introDismiss: {
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
marginTop: 12,
},
introDismissLink: {
color: '#dddddd',
display: 'flex',
alignItems: 'center',
marginBottom: 4,
'&:hover': {
color: theme.palette.linkHover,
transition: theme.transitions.create('color', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.shortest,
}),
},
},
introDismissText: {
fontSize: '0.7rem',
fontWeight: 'bold',
textTransform: 'uppercase',
letterSpacing: 1,
},
introDismissIcon: {
width: 18,
height: 18,
marginRight: 12,
},
}));
type IntroCardProps = {
text: string;
onClose: () => void;
};
export const IntroCard: FC<IntroCardProps> = props => {
const classes = useStyles();
const { text, onClose } = props;
const handleClose = () => onClose();
return (
<div className={classes.introCard}>
<Typography variant="subtitle2">{text}</Typography>
<div className={classes.introDismiss}>
<Link
component="button"
onClick={handleClose}
underline="none"
className={classes.introDismissLink}
>
<CloseIcon className={classes.introDismissIcon} />
<Typography component="span" className={classes.introDismissText}>
Dismiss
</Typography>
</Link>
</div>
</div>
);
};
type SidebarIntroLocalStorage = {
starredItemsDismissed: boolean;
recentlyViewedItemsDismissed: boolean;
};
type SidebarIntroCardProps = {
text: string;
onDismiss: () => void;
};
const SidebarIntroCard: FC<SidebarIntroCardProps> = props => {
const {text, onDismiss} = props
const [collapsing, setCollapsing] = useState(false)
const startDismissing = () => {
setCollapsing(true)
}
return (
<Collapse in={!collapsing} onExited={onDismiss}>
<IntroCard
text={text}
onClose={startDismissing}
/>
</Collapse>
);
};
const starredIntroText = `Fun fact! As you explore all the awesome plugins in Backstage, you can actually pin them to this side nav.
Keep an eye out for the little star icon (⭐) next to the plugin name and give it a click!`;
const recentlyViewedIntroText =
'And your recently viewed plugins will pop up here!';
export const SidebarIntro: FC = () => {
const { isOpen } = useContext(SidebarContext);
const [
{ starredItemsDismissed, recentlyViewedItemsDismissed },
setDismissedIntro,
] = useLocalStorage<SidebarIntroLocalStorage>(SIDEBAR_INTRO_LOCAL_STORAGE, {
starredItemsDismissed: false,
recentlyViewedItemsDismissed: false,
});
const dismissStarred = () => {
setDismissedIntro(state => ({ ...state, starredItemsDismissed: true }));
};
const dismissRecentlyViewed = () => {
setDismissedIntro(state => ({
...state,
recentlyViewedItemsDismissed: true,
}));
};
if (!isOpen) {
return null;
}
return (
<>
{!starredItemsDismissed && (
<>
<SidebarIntroCard text={starredIntroText} onDismiss={dismissStarred} />
<SidebarDivider />
</>
)}
{!recentlyViewedItemsDismissed && (
<SidebarIntroCard text={recentlyViewedIntroText} onDismiss={dismissRecentlyViewed} />
)}
</>
);
};
+159 -49
View File
@@ -14,87 +14,196 @@
* limitations under the License.
*/
import { Link, makeStyles, styled, Theme, Typography } from '@material-ui/core';
import {
makeStyles,
styled,
TextField,
Theme,
Typography,
Badge,
} from '@material-ui/core';
import SearchIcon from '@material-ui/icons/Search';
import clsx from 'clsx';
import React, { FC, useContext } from 'react';
import React, { FC, useContext, useState, KeyboardEventHandler } from 'react';
import { NavLink } from 'react-router-dom';
import { sidebarConfig, SidebarContext } from './config';
import { IconComponent } from '../../icons';
const useStyles = makeStyles<Theme>((theme) => ({
root: {
color: '#b5b5b5',
display: 'flex',
flexFlow: 'row nowrap',
alignItems: 'center',
height: 40,
cursor: 'pointer',
},
closed: {
width: sidebarConfig.drawerWidthClosed,
justifyContent: 'center',
},
open: {
width: sidebarConfig.drawerWidthOpen,
},
label: {
fontWeight: 'bolder',
whiteSpace: 'nowrap',
lineHeight: 1.0,
marginLeft: theme.spacing(1),
},
iconContainer: {
height: '100%',
width: sidebarConfig.drawerWidthClosed,
marginRight: -theme.spacing(2),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
}));
const useStyles = makeStyles<Theme>((theme) => {
const {
selectedIndicatorWidth,
drawerWidthClosed,
drawerWidthOpen,
iconContainerWidth,
iconSize,
} = sidebarConfig;
return {
root: {
color: '#b5b5b5',
display: 'flex',
flexFlow: 'row nowrap',
alignItems: 'center',
height: 48,
cursor: 'pointer',
},
closed: {
width: drawerWidthClosed,
justifyContent: 'center',
},
open: {
width: drawerWidthOpen,
},
label: {
// XXX (@koroeskohr): I can't seem to achieve the desired font-weight from the designs
fontWeight: 'bold',
whiteSpace: 'nowrap',
lineHeight: 1.0,
},
iconContainer: {
boxSizing: 'border-box',
height: '100%',
width: iconContainerWidth,
marginRight: -theme.spacing(2),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
icon: {
width: iconSize,
height: iconSize,
},
searchRoot: {
marginBottom: 12,
},
searchField: {
color: '#b5b5b5',
fontWeight: 'bold',
fontSize: theme.typography.fontSize,
},
searchContainer: {
width: drawerWidthOpen - iconContainerWidth,
},
selected: {
'&$root': {
borderLeft: `solid ${selectedIndicatorWidth}px #9BF0E1`,
color: '#ffffff',
},
'&$closed': {
width: drawerWidthClosed - selectedIndicatorWidth,
},
'& $iconContainer': {
marginLeft: -selectedIndicatorWidth,
},
},
};
});
type SidebarItemProps = {
icon: IconComponent;
text: string;
text?: string;
to?: string;
disableSelected?: boolean;
hasNotifications?: boolean;
onClick?: () => void;
};
export const SidebarItem: FC<SidebarItemProps> = ({
icon: Icon,
text,
to,
to = '#',
disableSelected = false,
hasNotifications = false,
onClick,
children,
}) => {
const classes = useStyles();
const isOpen = useContext(SidebarContext);
// XXX (@koroeskohr): unsure this is optimal. But I just really didn't want to have the item component
// depend on the current location, and at least have it being optionally forced to selected.
// Still waiting on a Q answered to fine tune the implementation
const { isOpen } = useContext(SidebarContext);
const itemIcon = (
<Badge
color="secondary"
variant="dot"
overlap="circle"
invisible={!hasNotifications}
>
<Icon fontSize="small" className={classes.icon} />
</Badge>
);
if (!isOpen) {
return (
<Link
<NavLink
className={clsx(classes.root, classes.closed)}
href={to}
activeClassName={classes.selected}
isActive={(match) => match && !disableSelected}
exact
to={to}
onClick={onClick}
underline="none"
>
<Icon fontSize="small" />
</Link>
{itemIcon}
</NavLink>
);
}
return (
<Link
<NavLink
className={clsx(classes.root, classes.open)}
href={to}
activeClassName={classes.selected}
isActive={(match) => match && !disableSelected}
exact
to={to}
onClick={onClick}
underline="none"
>
<div data-testid="login-button" className={classes.iconContainer}>
<Icon fontSize="small" />
{itemIcon}
</div>
<Typography variant="subtitle1" className={classes.label}>
{text}
</Typography>
</Link>
{text && (
<Typography variant="subtitle2" className={classes.label}>
{text}
</Typography>
)}
{children}
</NavLink>
);
};
type SidebarSearchFieldProps = {
onSearch: (input: string) => void;
};
export const SidebarSearchField: FC<SidebarSearchFieldProps> = (props) => {
const [input, setInput] = useState('');
const classes = useStyles();
const handleEnter: KeyboardEventHandler = (ev) => {
if (ev.key === 'Enter') {
props.onSearch(input);
}
};
const handleInput = (ev: React.ChangeEvent<HTMLInputElement>) => {
setInput(ev.target.value);
};
return (
<div className={classes.searchRoot}>
<SidebarItem icon={SearchIcon} disableSelected>
<TextField
placeholder="Search"
onChange={handleInput}
onKeyDown={handleEnter}
className={classes.searchContainer}
InputProps={{
disableUnderline: true,
className: classes.searchField,
}}
/>
</SidebarItem>
</div>
);
};
@@ -111,4 +220,5 @@ export const SidebarDivider = styled('hr')({
width: '100%',
background: '#383838',
border: 'none',
margin: 0,
});
@@ -0,0 +1,60 @@
/*
* Copyright 2020 Spotify AB
*
* 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 {
Sidebar,
SidebarIntro,
SidebarItem,
SidebarDivider,
SidebarSearchField,
SidebarSpace,
SidebarUserBadge,
} from '.';
import HomeOutlinedIcon from '@material-ui/icons/HomeOutlined';
import AddCircleOutlineIcon from '@material-ui/icons/AddCircleOutline';
import { MemoryRouter } from 'react-router-dom';
export default {
title: 'Sidebar',
component: Sidebar,
decorators: [
(storyFn: () => JSX.Element) => (
<MemoryRouter initialEntries={['/']}>{storyFn()}</MemoryRouter>
),
],
};
const handleSearch = (input: string) => {
// eslint-disable-next-line no-console
console.log(input);
};
export const SampleSidebar = () => (
<Sidebar>
{/* <SidebarLogo /> */}
<SidebarSearchField onSearch={handleSearch} />
<SidebarDivider />
<SidebarItem selected icon={HomeOutlinedIcon} to="#" text="Home" />
<SidebarItem icon={HomeOutlinedIcon} to="#" text="Plugins" />
<SidebarItem icon={AddCircleOutlineIcon} to="#" text="Create..." />
<SidebarDivider />
<SidebarIntro />
<SidebarSpace />
<SidebarDivider />
<SidebarUserBadge />
</Sidebar>
);
@@ -0,0 +1,72 @@
/*
* Copyright 2020 Spotify AB
*
* 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, { FC, useContext } from 'react';
import { Avatar, makeStyles, Theme, Typography } from '@material-ui/core';
import People from '@material-ui/icons/People';
import { sidebarConfig, SidebarContext } from './config';
import { SidebarItem } from './Items';
const useStyles = makeStyles<Theme>(() => {
const { drawerWidthOpen, userBadgeDiameter } = sidebarConfig;
return {
root: {
width: drawerWidthOpen,
display: 'flex',
alignItems: 'center',
color: '#b5b5b5',
paddingLeft: 18,
paddingTop: 14,
paddingBottom: 14,
},
avatar: {
width: userBadgeDiameter,
height: userBadgeDiameter,
marginRight: 8,
},
};
});
type Props = {
imageUrl: string;
name: string;
hideName?: boolean;
};
export const UserBadge: FC<Props> = ({ imageUrl, name, hideName = false }) => {
const classes = useStyles();
return (
<div className={classes.root}>
<Avatar alt={name} src={imageUrl} className={classes.avatar} />
{!hideName && <Typography variant="subtitle2">{name}</Typography>}
</div>
);
};
export const SidebarUserBadge: FC = () => {
const { isOpen } = useContext(SidebarContext);
const isUserLoggedIn = false;
return isUserLoggedIn ? (
<UserBadge
imageUrl="https://via.placeholder.com/200/200"
name="Victor Viale"
hideName={!isOpen}
/>
) : (
<SidebarItem icon={People} text="Log in" to="/login" disableSelected />
);
};
+28 -5
View File
@@ -16,11 +16,34 @@
import { createContext } from 'react';
const drawerWidthClosed = 72;
const iconPadding = 24;
const userBadgePadding = 18;
export const sidebarConfig = {
drawerWidthClosed: 64,
drawerWidthOpen: 220,
defaultOpenDelayMs: 400,
defaultCloseDelayMs: 200,
drawerWidthClosed,
drawerWidthOpen: 224,
// As per NN/g's guidance on timing for exposing hidden content
// See https://www.nngroup.com/articles/timing-exposing-content/
defaultOpenDelayMs: 300,
defaultCloseDelayMs: 0,
defaultFadeDuration: 200,
logoHeight: 32,
iconContainerWidth: drawerWidthClosed,
iconSize: drawerWidthClosed - iconPadding * 2,
iconPadding,
selectedIndicatorWidth: 3,
userBadgePadding,
userBadgeDiameter: drawerWidthClosed - userBadgePadding * 2,
};
export const SidebarContext = createContext<boolean>(false);
export const SIDEBAR_INTRO_LOCAL_STORAGE =
'@backstage/core/sidebar-intro-dismissed';
type SidebarContextType = {
isOpen: boolean;
};
export const SidebarContext = createContext<SidebarContextType>({
isOpen: false,
});
@@ -17,5 +17,7 @@
export * from './Bar';
export * from './Page';
export * from './Items';
export * from './Intro';
export * from './UserBadge';
export * from './config';
export { SidebarThemeToggle } from './SidebarThemeToggle';
+3 -3
View File
@@ -4327,9 +4327,9 @@
pretty-format "^25.1.0"
"@types/testing-library__jest-dom@^5.0.4":
version "5.0.4"
resolved "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.0.4.tgz#c7bfbafb920cd1ce40506474e70ee73637f33701"
integrity sha512-Ns69aaNvlxvXkPxIwsqeaWH5vJpwa/pdBIlf8LGkRnbV3tiqUgifs13moLXg1NQ2AM23qRR5CtHarNshvRyEdA==
version "5.6.0"
resolved "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.6.0.tgz#325e97aacb7e4a66693e7face8a2c04f936f4a4b"
integrity sha512-VRl4kIzvtySjscCpMul3mz0UgEd40nG/jWluaXIYi5UG8cOOLD56u8IIgHZk+gSKmccRCsVv7AAg1HBmE7OQ2w==
dependencies:
"@types/jest" "*"