diff --git a/.changeset/fluffy-countries-beam.md b/.changeset/fluffy-countries-beam.md new file mode 100644 index 0000000000..f542cfd33b --- /dev/null +++ b/.changeset/fluffy-countries-beam.md @@ -0,0 +1,10 @@ +--- +'@backstage/plugin-catalog': patch +'@backstage/plugin-cost-insights': patch +'@backstage/plugin-shortcuts': patch +'@backstage/plugin-user-settings': patch +--- + +**@backstage/plugin-user-settings:** Hide Header on mobile screens to improve the UI & give more space to the content. Furthermore, the "Pin Sidebar" setting is removed on mobile screens, as the mobile sidebar is always pinned to the bottom. + +**Other plugins:** Smaller style adjustments across plugins to improve the UI on mobile devices. diff --git a/.changeset/great-bulldogs-provide.md b/.changeset/great-bulldogs-provide.md new file mode 100644 index 0000000000..3b51ea9bdf --- /dev/null +++ b/.changeset/great-bulldogs-provide.md @@ -0,0 +1,11 @@ +--- +'@backstage/core-components': patch +--- + +The `Bar` component will now render a `MobileSidebar` instead of the current sidebar on smaller screens. The state of the `MobileSidebar` will be treated as always open. + +--- + +**Add MobileSidebar:** A navigation component, which sticks to the bottom. If there is no content in the Sidebar, it won't be rendered. If there are `children ` in the `Sidebar`, but no `SidebarGroup`s as `children`, it will render all `children` into a default overlay menu, which can be displayed by clicking a menu item. If `SidebarGroup`s are provided, it will render them in the bottom navigation. Additionally, a `MobileSidebarContext`, which wraps the component, will save the selected menu item. + +**Add SidebarGroup:** Groups items of the `Sidebar` together. On bigger screens, this won't have any effect at the moment. On smaller screens, it will render a given icon into the `MobileSidebar`. If a route is provided, clicking the `SidebarGroup` in the `MobileSidebar` will route to the page. If no route is provided, it will add a provided icon to the `MobileSidebar` as a menu item & will render the children into an overlay menu, which will be displayed when the menu item is clicked. diff --git a/.changeset/stale-brooms-fry.md b/.changeset/stale-brooms-fry.md new file mode 100644 index 0000000000..18c847973e --- /dev/null +++ b/.changeset/stale-brooms-fry.md @@ -0,0 +1,51 @@ +--- +'@backstage/create-app': patch +--- + +You can now add `SidebarGroup`s to the current `Sidebar`. This will not affect how the current sidebar is displayed, but allows a customization on how the `MobileSidebar` on smaller screens will look like. A `SidebarGroup` will be displayed with the given icon in the `MobileSidebar`. + +A `SidebarGroup` can either link to an existing page (e.g. `/search` or `/settings`) or wrap components, which will be displayed in a full-screen overlay menu (e.g. `Menu`). + +```diff + + ++ } to="/search"> + ++ + ++ }> + + + + + + ++ + + ++ } ++ to="/settings" ++ > + ++ + +``` + +Additionally, you can order the groups differently in the `MobileSidebar` than in the usual `Sidebar` simply by giving a group a priority. The groups will be displayed in descending order from left to right. + +```diff +} + to="/settings" ++ priority={1} +> + + +``` + +If you decide against adding `SidebarGroup`s to your `Sidebar` the `MobileSidebar` will contain one default menu item, which will open a full-screen overlay menu displaying all the content of the current `Sidebar`. + +More information on the `SidebarGroup` & the `MobileSidebar` component can be found in the changeset for the `core-components`. diff --git a/docs/getting-started/configure-app-with-plugins.md b/docs/getting-started/configure-app-with-plugins.md index 746b4bd940..1bfabc335c 100644 --- a/docs/getting-started/configure-app-with-plugins.md +++ b/docs/getting-started/configure-app-with-plugins.md @@ -90,3 +90,29 @@ extension to `.icon.svg`. For example: ```ts import InternalToolIcon from './internal-tool.icon.svg'; ``` + +On mobile devices the `Sidebar` is displayed at the bottom of the screen. For +customizing the experience you can group `SidebarItems` in a `SidebarGroup` +(Example 1) or create a `SidebarGroup` with a link (Example 2). All +`SidebarGroup`s are displayed in the bottom navigation with an icon. + +```ts +// Example 1 +} label="Menu"> + ... + + ... + +``` + +```ts +// Example 2 +} to="/search"> + ... + + ... + +``` + +If no `SidebarGroup` is provided a default menu will display the `Sidebar` +content. diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 2d9b9b97f1..57f1fa536f 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -23,12 +23,17 @@ import MapIcon from '@material-ui/icons/MyLocation'; import LayersIcon from '@material-ui/icons/Layers'; import LibraryBooks from '@material-ui/icons/LibraryBooks'; import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; +import SearchIcon from '@material-ui/icons/Search'; +import MenuIcon from '@material-ui/icons/Menu'; import MoneyIcon from '@material-ui/icons/MonetizationOn'; import LogoFull from './LogoFull'; import LogoIcon from './LogoIcon'; import { NavLink } from 'react-router-dom'; import { GraphiQLIcon } from '@backstage/plugin-graphiql'; -import { Settings as SidebarSettings } from '@backstage/plugin-user-settings'; +import { + Settings as SidebarSettings, + UserSettingsSignInAvatar, +} from '@backstage/plugin-user-settings'; import { SidebarSearchModal, SearchContextProvider, @@ -36,15 +41,15 @@ import { import { Shortcuts } from '@backstage/plugin-shortcuts'; import { Sidebar, - SidebarPage, sidebarConfig, SidebarContext, - SidebarItem, SidebarDivider, - SidebarSpace, + SidebarGroup, + SidebarItem, + SidebarPage, SidebarScrollWrapper, + SidebarSpace, } from '@backstage/core-components'; -import { AzurePullRequestsIcon } from '@backstage/plugin-azure-devops'; const useSidebarLogoStyles = makeStyles({ root: { @@ -83,34 +88,43 @@ export const Root = ({ children }: PropsWithChildren<{}>) => ( - - - + } to="/search"> + + + + - {/* Global nav, not org-specific */} - - - - - - {/* End global nav */} - - - - - - - - - - + }> + {/* Global nav, not org-specific */} + + + + + + {/* End global nav */} + + + + + + + + + + - + } + to="/settings" + > + + {children} diff --git a/packages/app/src/components/search/SearchPage.tsx b/packages/app/src/components/search/SearchPage.tsx index c99bb78c84..9a8c208103 100644 --- a/packages/app/src/components/search/SearchPage.tsx +++ b/packages/app/src/components/search/SearchPage.tsx @@ -21,6 +21,7 @@ import { Header, Lifecycle, Page, + SidebarPinStateContext, } from '@backstage/core-components'; import { CatalogResultListItem } from '@backstage/plugin-catalog'; import { @@ -33,7 +34,7 @@ import { } from '@backstage/plugin-search'; import { DocsResultListItem } from '@backstage/plugin-techdocs'; import { Grid, List, makeStyles, Paper, Theme } from '@material-ui/core'; -import React from 'react'; +import React, { useContext } from 'react'; const useStyles = makeStyles((theme: Theme) => ({ bar: { @@ -52,9 +53,11 @@ const useStyles = makeStyles((theme: Theme) => ({ const SearchPage = () => { const classes = useStyles(); + const { isMobile } = useContext(SidebarPinStateContext); + return ( -
} /> + {!isMobile &&
} />} @@ -62,37 +65,39 @@ const SearchPage = () => { - - , - }, - { - value: 'techdocs', - name: 'Documentation', - icon: , - }, - ]} - /> - - + , + }, + { + value: 'techdocs', + name: 'Documentation', + icon: , + }, + ]} /> - - - - + + + + + + )} + {({ results }) => ( diff --git a/packages/core-components/api-report.md b/packages/core-components/api-report.md index 29847b079e..8b5d1c61a7 100644 --- a/packages/core-components/api-report.md +++ b/packages/core-components/api-report.md @@ -10,6 +10,7 @@ import { BackstageIdentityApi } from '@backstage/core-plugin-api'; import { BackstagePalette } from '@backstage/theme'; import { BackstageTheme } from '@backstage/theme'; import { BackstageUserIdentity } from '@backstage/core-plugin-api'; +import { BottomNavigationActionProps } from '@material-ui/core/BottomNavigationAction'; import { ButtonProps as ButtonProps_2 } from '@material-ui/core/Button'; import { CardHeaderProps } from '@material-ui/core/CardHeader'; import { Column } from '@material-table/core'; @@ -105,7 +106,7 @@ export type BottomLinkProps = { // Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts // // @public -export function Breadcrumbs(props: Props_20): JSX.Element; +export function Breadcrumbs(props: Props_19): JSX.Element; // @public (undocumented) export type BreadcrumbsClickableTextClassKey = 'root'; @@ -696,6 +697,14 @@ export function MissingAnnotationEmptyState(props: Props_3): JSX.Element; // @public (undocumented) export type MissingAnnotationEmptyStateClassKey = 'code'; +// @public +export const MobileSidebar: (props: MobileSidebarProps) => JSX.Element | null; + +// @public +export type MobileSidebarProps = { + children?: React_2.ReactNode; +}; + // Warning: (ae-missing-release-tag) "OAuthRequestDialog" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -845,11 +854,8 @@ export type SelectItem = { value: string | number; }; -// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "Sidebar" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export function Sidebar(props: PropsWithChildren): JSX.Element; +// @public +export const Sidebar: (props: SidebarProps) => JSX.Element; // Warning: (ae-missing-release-tag) "SIDEBAR_INTRO_LOCAL_STORAGE" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -857,10 +863,8 @@ export function Sidebar(props: PropsWithChildren): JSX.Element; export const SIDEBAR_INTRO_LOCAL_STORAGE = '@backstage/core/sidebar-intro-dismissed'; -// Warning: (ae-missing-release-tag) "SidebarClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) -export type SidebarClassKey = 'root' | 'drawer' | 'drawerOpen'; +export type SidebarClassKey = 'drawer' | 'drawerOpen'; // Warning: (ae-missing-release-tag) "sidebarConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -878,16 +882,17 @@ export const sidebarConfig: { selectedIndicatorWidth: number; userBadgePadding: number; userBadgeDiameter: number; + mobileSidebarHeight: number; }; // Warning: (ae-missing-release-tag) "SidebarContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) +// @public export const SidebarContext: Context; // Warning: (ae-missing-release-tag) "SidebarContextType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) +// @public export type SidebarContextType = { isOpen: boolean; setOpen: (open: boolean) => void; @@ -1170,6 +1175,16 @@ export type SidebarDividerClassKey = 'root'; // @public export const SidebarExpandButton: () => JSX.Element | null; +// @public +export const SidebarGroup: (props: SidebarGroupProps) => JSX.Element; + +// @public +export interface SidebarGroupProps extends BottomNavigationActionProps { + children?: React_2.ReactNode; + priority?: number; + to?: string; +} + // 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) @@ -1186,19 +1201,19 @@ export type SidebarIntroClassKey = // Warning: (ae-forgotten-export) The symbol "SidebarItemProps" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "SidebarItem" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) +// @public export const SidebarItem: React_2.ForwardRefExoticComponent< SidebarItemProps & React_2.RefAttributes >; -// Warning: (ae-missing-release-tag) "SidebarItemClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type SidebarItemClassKey = | 'root' | 'buttonItem' | 'closed' | 'open' + | 'highlightable' + | 'highlighted' | 'label' | 'iconContainer' | 'searchRoot' @@ -1206,29 +1221,43 @@ export type SidebarItemClassKey = | 'searchFieldHTMLInput' | 'searchContainer' | 'secondaryAction' + | 'closedItemIcon' + | 'submenuArrow' + | 'expandButton' + | 'arrows' | 'selected'; // Warning: (ae-missing-release-tag) "SidebarPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function SidebarPage(props: PropsWithChildren<{}>): JSX.Element; +export function SidebarPage(props: SidebarPageProps): JSX.Element; // Warning: (ae-missing-release-tag) "SidebarPageClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export type SidebarPageClassKey = 'root'; -// Warning: (ae-missing-release-tag) "SidebarPinStateContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public +export type SidebarPageProps = { + children?: React_2.ReactNode; +}; + +// @public export const SidebarPinStateContext: React_2.Context; -// Warning: (ae-missing-release-tag) "SidebarPinStateContextType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export type SidebarPinStateContextType = { isPinned: boolean; toggleSidebarPinState: () => any; + isMobile?: boolean; +}; + +// @public (undocumented) +export type SidebarProps = { + openDelayMs?: number; + closeDelayMs?: number; + disableExpandOnHover?: boolean; + children?: React_2.ReactNode; }; // Warning: (ae-missing-release-tag) "SidebarScrollWrapper" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -2086,7 +2115,7 @@ export type SidebarSubmenuProps = { // Warning: (ae-missing-release-tag) "SignInPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function SignInPage(props: Props_18): JSX.Element; +export function SignInPage(props: Props_17): JSX.Element; // Warning: (ae-missing-release-tag) "SignInPageClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -2257,7 +2286,7 @@ export type TabBarClassKey = 'indicator' | 'flexContainer' | 'root'; // Warning: (ae-missing-release-tag) "TabbedCard" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function TabbedCard(props: PropsWithChildren): JSX.Element; +export function TabbedCard(props: PropsWithChildren): JSX.Element; // Warning: (ae-missing-release-tag) "TabbedCardClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // diff --git a/packages/core-components/src/layout/Page/Page.tsx b/packages/core-components/src/layout/Page/Page.tsx index 57653d23b5..b013affee3 100644 --- a/packages/core-components/src/layout/Page/Page.tsx +++ b/packages/core-components/src/layout/Page/Page.tsx @@ -20,7 +20,7 @@ import { makeStyles, ThemeProvider } from '@material-ui/core/styles'; export type PageClassKey = 'root'; -const useStyles = makeStyles( +const useStyles = makeStyles( () => ({ root: { display: 'grid', @@ -28,7 +28,7 @@ const useStyles = makeStyles( "'pageHeader pageHeader pageHeader' 'pageSubheader pageSubheader pageSubheader' 'pageNav pageContent pageSidebar'", gridTemplateRows: 'max-content auto 1fr', gridTemplateColumns: 'auto 1fr auto', - height: '100vh', + height: '100%', overflowY: 'auto', }, }), diff --git a/packages/core-components/src/layout/Sidebar/Bar.test.tsx b/packages/core-components/src/layout/Sidebar/Bar.test.tsx index d288aa94c5..89133997f9 100644 --- a/packages/core-components/src/layout/Sidebar/Bar.test.tsx +++ b/packages/core-components/src/layout/Sidebar/Bar.test.tsx @@ -14,24 +14,32 @@ * limitations under the License. */ -import React from 'react'; import { renderInTestApp } from '@backstage/test-utils'; +import AcUnitIcon from '@material-ui/icons/AcUnit'; +import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; +import BuildRoundedIcon from '@material-ui/icons/BuildRounded'; +import MenuBookIcon from '@material-ui/icons/MenuBook'; 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 '.'; +import React from 'react'; +import { + Sidebar, + SidebarExpandButton, + SidebarItem, + SidebarSearchField, + SidebarPinStateContext, + SidebarSubmenu, + SidebarSubmenuItem, +} from '.'; async function renderScalableSidebar() { await renderInTestApp( {} }} + value={{ + isPinned: false, + isMobile: false, + toggleSidebarPinState: () => {}, + }} > {}} to="/search" /> diff --git a/packages/core-components/src/layout/Sidebar/Bar.tsx b/packages/core-components/src/layout/Sidebar/Bar.tsx index bada68a028..ff907cdda3 100644 --- a/packages/core-components/src/layout/Sidebar/Bar.tsx +++ b/packages/core-components/src/layout/Sidebar/Bar.tsx @@ -17,23 +17,17 @@ import { makeStyles } from '@material-ui/core/styles'; import useMediaQuery from '@material-ui/core/useMediaQuery'; import classnames from 'classnames'; -import React, { useState, useContext, PropsWithChildren, useRef } from 'react'; +import React, { useState, useContext, 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'; +import { MobileSidebar } from './MobileSidebar'; -export type SidebarClassKey = 'root' | 'drawer' | 'drawerOpen'; +/** @public */ +export type SidebarClassKey = 'drawer' | 'drawerOpen'; const useStyles = makeStyles( theme => ({ - root: { - zIndex: 1000, - position: 'relative', - overflow: 'visible', - width: theme.spacing(7) + 1, - }, drawer: { display: 'flex', flexFlow: 'column nowrap', @@ -42,7 +36,7 @@ const useStyles = makeStyles( left: 0, top: 0, bottom: 0, - padding: 0, + zIndex: theme.zIndex.appBar, background: theme.palette.navigation.background, overflowX: 'hidden', msOverflowStyle: 'none', @@ -59,19 +53,6 @@ const useStyles = makeStyles( 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', { @@ -89,29 +70,44 @@ enum State { Open, } -type Props = { +/** @public */ +export type SidebarProps = { openDelayMs?: number; closeDelayMs?: number; disableExpandOnHover?: boolean; + children?: React.ReactNode; }; -export function Sidebar(props: PropsWithChildren) { +/** + * Places the Sidebar & wraps the children providing context weather the `Sidebar` is open or not. + * + * Handles & delays hover events for expanding the `Sidebar` + * + * @param props `disableExpandOnHover` disables the default hover behaviour; + * `openDelayMs` & `closeDelayMs` set delay until sidebar will open/close on hover + * @returns + * @internal + */ +const DesktopSidebar = (props: SidebarProps) => { const { - disableExpandOnHover = false, openDelayMs = sidebarConfig.defaultOpenDelayMs, closeDelayMs = sidebarConfig.defaultCloseDelayMs, + disableExpandOnHover, children, } = props; const classes = useStyles(); - const isSmallScreen = useMediaQuery(theme => - theme.breakpoints.down('md'), + const isSmallScreen = useMediaQuery( + theme => theme.breakpoints.down('md'), + { noSsr: true }, ); const [state, setState] = useState(State.Closed); const hoverTimerRef = useRef(); - const { isPinned } = useContext(SidebarPinStateContext); + const { isPinned, toggleSidebarPinState } = useContext( + SidebarPinStateContext, + ); const handleOpen = () => { - if (isPinned) { + if (isPinned || disableExpandOnHover) { return; } if (hoverTimerRef.current) { @@ -129,7 +125,7 @@ export function Sidebar(props: PropsWithChildren) { }; const handleClose = () => { - if (isPinned) { + if (isPinned || disableExpandOnHover) { return; } if (hoverTimerRef.current) { @@ -148,73 +144,60 @@ export function Sidebar(props: PropsWithChildren) { const isOpen = (state === State.Open && !isSmallScreen) || isPinned; + /** + * Close/Open Sidebar directily without delays. Also toggles `SidebarPinState` to avoid hidden content behind Sidebar. + */ const setOpen = (open: boolean) => { if (open) { - handleOpen(); + setState(State.Open); + toggleSidebarPinState(); } else { - handleClose(); + setState(State.Closed); + toggleSidebarPinState(); } }; return ( -
{} : handleOpen} - onFocus={disableExpandOnHover ? () => {} : handleOpen} - onMouseLeave={disableExpandOnHover ? () => {} : handleClose} - onBlur={disableExpandOnHover ? () => {} : handleClose} + - -
- {children} -
-
-
+ {children} + + ); -} +}; /** - * 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. + * Passing children into the desktop or mobile sidebar depending on the context * * @public */ -export const SidebarExpandButton = () => { - const classes = useStyles(); - const { isOpen, setOpen } = useContext(SidebarContext); - const { isPinned } = useContext(SidebarPinStateContext); - const isSmallScreen = useMediaQuery(theme => - theme.breakpoints.down('md'), - ); +export const Sidebar = (props: SidebarProps) => { + const { children, openDelayMs, closeDelayMs, disableExpandOnHover } = props; + const { isMobile } = useContext(SidebarPinStateContext); - const handleClick = () => { - setOpen(!isOpen); - }; - - if (isPinned || isSmallScreen) { - return null; - } - - return ( - + {children} + ); }; diff --git a/packages/core-components/src/layout/Sidebar/Items.test.tsx b/packages/core-components/src/layout/Sidebar/Items.test.tsx index f4d699d09c..b2cabd03b8 100644 --- a/packages/core-components/src/layout/Sidebar/Items.test.tsx +++ b/packages/core-components/src/layout/Sidebar/Items.test.tsx @@ -20,8 +20,8 @@ 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, SidebarExpandButton } from './Bar'; -import { SidebarItem, SidebarSearchField } from './Items'; +import { Sidebar } from './Bar'; +import { SidebarItem, SidebarSearchField, SidebarExpandButton } from './Items'; import { renderHook } from '@testing-library/react-hooks'; import { hexToRgb, makeStyles } from '@material-ui/core/styles'; diff --git a/packages/core-components/src/layout/Sidebar/Items.tsx b/packages/core-components/src/layout/Sidebar/Items.tsx index 7d00db1b1a..b16c9ad302 100644 --- a/packages/core-components/src/layout/Sidebar/Items.tsx +++ b/packages/core-components/src/layout/Sidebar/Items.tsx @@ -17,18 +17,19 @@ import { IconComponent, useElementFilter } from '@backstage/core-plugin-api'; import { BackstageTheme } from '@backstage/theme'; import { makeStyles, styled, Theme } from '@material-ui/core/styles'; +import useMediaQuery from '@material-ui/core/useMediaQuery'; 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 ArrowDropUp from '@material-ui/icons/ArrowDropUp'; +import ArrowDropDown from '@material-ui/icons/ArrowDropDown'; import classnames from 'classnames'; import React, { - Children, forwardRef, KeyboardEventHandler, - PropsWithChildren, ReactNode, useContext, useState, @@ -45,15 +46,24 @@ import { SidebarContext, SidebarItemWithSubmenuContext, } from './config'; -import { SidebarSubmenu } from './SidebarSubmenu'; +import { + SidebarSubmenuItemProps, + SidebarSubmenuProps, + SidebarSubmenu, +} from '.'; +import DoubleArrowLeft from './icons/DoubleArrowLeft'; +import DoubleArrowRight from './icons/DoubleArrowRight'; import { isLocationMatch } from './utils'; import { Location } from 'history'; +/** @public */ export type SidebarItemClassKey = | 'root' | 'buttonItem' | 'closed' | 'open' + | 'highlightable' + | 'highlighted' | 'label' | 'iconContainer' | 'searchRoot' @@ -61,6 +71,10 @@ export type SidebarItemClassKey = | 'searchFieldHTMLInput' | 'searchContainer' | 'secondaryAction' + | 'closedItemIcon' + | 'submenuArrow' + | 'expandButton' + | 'arrows' | 'selected'; const useStyles = makeStyles( @@ -83,7 +97,7 @@ const useStyles = makeStyles( buttonItem: { background: 'none', border: 'none', - width: 'auto', + width: '100%', margin: 0, padding: 0, textAlign: 'inherit', @@ -94,7 +108,9 @@ const useStyles = makeStyles( justifyContent: 'center', }, open: { - width: drawerWidthOpen, + [theme.breakpoints.up('sm')]: { + width: drawerWidthOpen, + }, }, highlightable: { '&:hover': { @@ -134,7 +150,7 @@ const useStyles = makeStyles( fontSize: theme.typography.fontSize, }, searchFieldHTMLInput: { - padding: `${theme.spacing(2)} 0 ${theme.spacing(2)}`, + padding: theme.spacing(2, 0, 2), }, searchContainer: { width: drawerWidthOpen - iconContainerWidth, @@ -149,8 +165,20 @@ const useStyles = makeStyles( justifyContent: 'center', }, submenuArrow: { + display: 'flex', + }, + expandButton: { + background: 'none', + border: 'none', + color: theme.palette.navigation.color, + width: '100%', + cursor: 'pointer', + position: 'relative', + height: 48, + }, + arrows: { position: 'absolute', - right: 0, + right: 10, }, selected: { '&$root': { @@ -172,112 +200,49 @@ const useStyles = makeStyles( { name: 'BackstageSidebarItem' }, ); -function isSidebarItemWithSubmenuActive( - submenu: ReactNode, - currentLocation: Location, -) { - // 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 toLocation = resolvePath(to); - return isLocationMatch(currentLocation, toLocation); - }); - return isActive; -} - -const SidebarItemWithSubmenu = ({ - text, - hasNotifications = false, - icon: Icon, - children, -}: PropsWithChildren) => { - const classes = useStyles(); - const [isHoveredOn, setIsHoveredOn] = useState(false); - const currentLocation = useLocation(); - const isActive = isSidebarItemWithSubmenuActive(children, currentLocation); - - const handleMouseEnter = () => { - setIsHoveredOn(true); - }; - const handleMouseLeave = () => { - setIsHoveredOn(false); - }; - - const { isOpen } = useContext(SidebarContext); - const itemIcon = ( - - - +/** + * Evaluates the routes of the SubmenuItems & nested DropdownItems. + * The reeveluation is only triggered, if the `locationPathname` changes, as `useElementFilter` uses memorization. + * + * @param submenu SidebarSubmenu component + * @param location Location + * @returns boolean + */ +const useLocationMatch = ( + submenu: React.ReactElement, + location: Location, +): boolean => + useElementFilter( + submenu.props.children, + elements => { + let active = false; + elements + .getElements() + .forEach( + ({ + props: { to, dropdownItems }, + }: { + props: Partial; + }) => { + if (!active) { + if (dropdownItems?.length) { + dropdownItems.forEach( + ({ to: _to }) => + (active = + active || isLocationMatch(location, resolvePath(_to))), + ); + return; + } + if (to) { + active = isLocationMatch(location, resolvePath(to)); + } + } + }, + ); + return active; + }, + [location.pathname], ); - const openContent = ( - <> -
- {itemIcon} -
- {text && ( - - {text} - - )} -
{}
- - ); - const closedContent = itemIcon; - - return ( - -
-
- {isOpen ? openContent : closedContent} - {!isHoveredOn && ( - - )} -
- {isHoveredOn && children} -
-
- ); -}; type SidebarItemBaseProps = { icon: IconComponent; @@ -370,7 +335,10 @@ export const WorkaroundNavLink = React.forwardRef< ); }); -export const SidebarItem = forwardRef((props, ref) => { +/** + * Common component used by SidebarItem & SidebarItemWithSubmenu + */ +const SidebarItemBase = forwardRef((props, ref) => { const { icon: Icon, text, @@ -399,8 +367,6 @@ export const SidebarItem = forwardRef((props, ref) => { ); - const closedContent = itemIcon; - const openContent = ( <>
@@ -415,7 +381,7 @@ export const SidebarItem = forwardRef((props, ref) => { ); - const content = isOpen ? openContent : closedContent; + const content = isOpen ? openContent : itemIcon; const childProps = { onClick, @@ -428,39 +394,6 @@ export const SidebarItem = forwardRef((props, ref) => { ), }; - let hasSubmenu = false; - let submenu: ReactNode; - const componentType = ( - - <> - - ).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 ( - - {submenu} - - ); - } - if (isButtonItem(props)) { return ( + ); +}; diff --git a/packages/core-components/src/layout/Sidebar/MobileSidebar.test.tsx b/packages/core-components/src/layout/Sidebar/MobileSidebar.test.tsx new file mode 100644 index 0000000000..8dd06b6e2b --- /dev/null +++ b/packages/core-components/src/layout/Sidebar/MobileSidebar.test.tsx @@ -0,0 +1,105 @@ +/* + * 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 { mockBreakpoint, renderInTestApp } from '@backstage/test-utils'; +import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; +import HomeIcon from '@material-ui/icons/Home'; +import LayersIcon from '@material-ui/icons/Layers'; +import LibraryBooks from '@material-ui/icons/LibraryBooks'; +import { fireEvent } from '@testing-library/react'; +import React from 'react'; +import { + MobileSidebar, + Sidebar, + SidebarGroup, + SidebarItem, + SidebarPage, +} from '.'; + +const MobileSidebarWithGroups = () => ( + + +

Header

+ } label="Menu"> + + + + +
Content
+
More Content
+ } label="Create" to="#" /> +
Footer
+
+
+); + +const MobileSidebarWithoutGroups = () => ( + + + + + + + +); + +describe('', () => { + beforeEach(() => { + mockBreakpoint({ matches: true }); + }); + + it('should render MobileSidebar on smaller screens', async () => { + const { getByTestId } = await renderInTestApp( + + + + + , + ); + expect(getByTestId('mobile-sidebar-root')).toBeVisible(); + }); + + it('should render only SidebarGroups inside MobileSidebar', async () => { + const { findAllByRole, getByTestId, findByText } = await renderInTestApp( + , + ); + expect(getByTestId('mobile-sidebar-root').children.length).toBe(2); + expect((await findAllByRole('button')).length).toBe(2); + expect(await findByText('Menu')).toBeValid(); + expect(await findByText('Create')).toBeValid(); + }); + + it('should render default MobileSidebar when there are no SidebarGroups', async () => { + const { findAllByRole, getByTestId } = await renderInTestApp( + , + ); + expect(getByTestId('mobile-sidebar-root').children.length).toBe(1); + const defaultSidebarGroup = await findAllByRole('button'); + expect(defaultSidebarGroup.length).toBe(1); + }); + + it('should render OverlayMenu displaying SidebarItems', async () => { + const { findByText, getByRole } = await renderInTestApp( + , + ); + const menuButton = await findByText('Menu'); + fireEvent.click(menuButton); + expect(getByRole('heading', { name: 'Menu' })).toBeVisible(); + expect(getByRole('link', { name: 'Home' })).toBeVisible(); + expect(getByRole('link', { name: 'Explore' })).toBeVisible(); + expect(getByRole('link', { name: 'Docs' })).toBeVisible(); + }); +}); diff --git a/packages/core-components/src/layout/Sidebar/MobileSidebar.tsx b/packages/core-components/src/layout/Sidebar/MobileSidebar.tsx new file mode 100644 index 0000000000..3819f21a02 --- /dev/null +++ b/packages/core-components/src/layout/Sidebar/MobileSidebar.tsx @@ -0,0 +1,215 @@ +/* + * 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 { useElementFilter } from '@backstage/core-plugin-api'; +import { BackstageTheme } from '@backstage/theme'; +import BottomNavigation from '@material-ui/core/BottomNavigation'; +import Box from '@material-ui/core/Box'; +import IconButton from '@material-ui/core/IconButton'; +import { makeStyles } from '@material-ui/core/styles'; +import Drawer from '@material-ui/core/Drawer'; +import Typography from '@material-ui/core/Typography'; +import CloseIcon from '@material-ui/icons/Close'; +import MenuIcon from '@material-ui/icons/Menu'; +import { orderBy } from 'lodash'; +import React, { createContext, useEffect, useState } from 'react'; +import { useLocation } from 'react-router'; +import { SidebarContext } from '.'; +import { sidebarConfig } from './config'; +import { SidebarGroup } from './SidebarGroup'; + +/** + * Type of `MobileSidebarContext` + * + * @internal + */ +export type MobileSidebarContextType = { + selectedMenuItemIndex: number; + setSelectedMenuItemIndex: React.Dispatch>; +}; + +/** + * Props of MobileSidebar + * + * @public + */ +export type MobileSidebarProps = { + children?: React.ReactNode; +}; + +/** + * @internal + */ +type OverlayMenuProps = { + label?: string; + onClose: () => void; + open: boolean; + children?: React.ReactNode; +}; + +const useStyles = makeStyles(theme => ({ + root: { + position: 'fixed', + backgroundColor: theme.palette.navigation.background, + color: theme.palette.navigation.color, + bottom: 0, + left: 0, + right: 0, + zIndex: theme.zIndex.snackbar, + // SidebarDivider color + borderTop: '1px solid #383838', + }, + + overlay: { + background: theme.palette.navigation.background, + width: '100%', + bottom: `${sidebarConfig.mobileSidebarHeight}px`, + height: `calc(100% - ${sidebarConfig.mobileSidebarHeight}px)`, + flex: '0 1 auto', + overflow: 'auto', + }, + + overlayHeader: { + display: 'flex', + color: theme.palette.bursts.fontColor, + alignItems: 'center', + justifyContent: 'space-between', + padding: theme.spacing(2, 3), + }, + + overlayHeaderClose: { + color: theme.palette.bursts.fontColor, + }, +})); + +const sortSidebarGroupsForPriority = (children: React.ReactElement[]) => + orderBy( + children, + ({ props: { priority } }) => (Number.isInteger(priority) ? priority : -1), + 'desc', + ); + +const OverlayMenu = ({ + children, + label = 'Menu', + open, + onClose, +}: OverlayMenuProps) => { + const classes = useStyles(); + + return ( + + + {label} + + + + + {children} + + ); +}; + +/** + * Context on which `SidebarGroup` is currently selected + * + * @internal + */ +export const MobileSidebarContext = createContext({ + selectedMenuItemIndex: -1, + setSelectedMenuItemIndex: () => {}, +}); + +/** + * A navigation component for mobile screens, which sticks to the bottom. + * + * It alternates the normal sidebar by grouping the `SidebarItems` based on provided `SidebarGroup`s + * either rendering them as a link or an overlay menu. + * If no `SidebarGroup`s are provided the sidebar content is wrapped in an default overlay menu. + * + * @public + */ +export const MobileSidebar = (props: MobileSidebarProps) => { + const { children } = props; + const classes = useStyles(); + const location = useLocation(); + const [selectedMenuItemIndex, setSelectedMenuItemIndex] = + useState(-1); + + useEffect(() => { + setSelectedMenuItemIndex(-1); + }, [location.pathname]); + + // Filter children for SidebarGroups + let sidebarGroups = useElementFilter(children, elements => + elements.getElements().filter(child => child.type === SidebarGroup), + ); + + if (!children) { + // If Sidebar has no children the MobileSidebar won't be rendered + return null; + } else if (!sidebarGroups.length) { + // If Sidebar has no SidebarGroup as a children a default + // SidebarGroup with the complete Sidebar content will be created + sidebarGroups.push( + }> + {children} + , + ); + } else { + // Sort SidebarGroups for the given Priority + sidebarGroups = sortSidebarGroupsForPriority(sidebarGroups); + } + + const shouldShowGroupChildren = + selectedMenuItemIndex >= 0 && + !sidebarGroups[selectedMenuItemIndex].props.to; + + return ( + {} }}> + + setSelectedMenuItemIndex(-1)} + > + {sidebarGroups[selectedMenuItemIndex] && + (sidebarGroups[selectedMenuItemIndex].props + .children as React.ReactChildren)} + + + {sidebarGroups} + + + + ); +}; diff --git a/packages/core-components/src/layout/Sidebar/Page.tsx b/packages/core-components/src/layout/Sidebar/Page.tsx index 6248c237b2..f23a6fa3b6 100644 --- a/packages/core-components/src/layout/Sidebar/Page.tsx +++ b/packages/core-components/src/layout/Sidebar/Page.tsx @@ -15,46 +15,67 @@ */ import { makeStyles } from '@material-ui/core/styles'; -import React, { - createContext, - PropsWithChildren, - useEffect, - useState, -} from 'react'; +import React, { createContext, useEffect, useState } from 'react'; import { sidebarConfig } from './config'; import { BackstageTheme } from '@backstage/theme'; import { LocalStorage } from './localStorage'; +import useMediaQuery from '@material-ui/core/useMediaQuery'; export type SidebarPageClassKey = 'root'; const useStyles = makeStyles( - { + theme => ({ root: { width: '100%', - minHeight: '100%', transition: 'padding-left 0.1s ease-out', - paddingLeft: ({ isPinned }) => - isPinned - ? sidebarConfig.drawerWidthOpen - : sidebarConfig.drawerWidthClosed, + [theme.breakpoints.up('sm')]: { + paddingLeft: ({ isPinned }) => + isPinned + ? sidebarConfig.drawerWidthOpen + : sidebarConfig.drawerWidthClosed, + }, + [theme.breakpoints.down('xs')]: { + paddingBottom: sidebarConfig.mobileSidebarHeight, + }, }, - }, + }), { name: 'BackstageSidebarPage' }, ); +/** + * Type of `SidebarPinStateContext` + * + * @public + */ export type SidebarPinStateContextType = { isPinned: boolean; toggleSidebarPinState: () => any; + isMobile?: boolean; }; +/** + * Props for SidebarPage + * + * @public + */ +export type SidebarPageProps = { + children?: React.ReactNode; +}; + +/** + * Contains the state on how the `Sidebar` is rendered + * + * @public + */ export const SidebarPinStateContext = createContext( { isPinned: true, toggleSidebarPinState: () => {}, + isMobile: false, }, ); -export function SidebarPage(props: PropsWithChildren<{}>) { +export function SidebarPage(props: SidebarPageProps) { const [isPinned, setIsPinned] = useState(() => LocalStorage.getSidebarPinState(), ); @@ -63,6 +84,11 @@ export function SidebarPage(props: PropsWithChildren<{}>) { LocalStorage.setSidebarPinState(isPinned); }, [isPinned]); + const isMobile = useMediaQuery( + theme => theme.breakpoints.down('xs'), + { noSsr: true }, + ); + const toggleSidebarPinState = () => setIsPinned(!isPinned); const classes = useStyles({ isPinned }); @@ -71,6 +97,7 @@ export function SidebarPage(props: PropsWithChildren<{}>) { value={{ isPinned, toggleSidebarPinState, + isMobile, }} >
{props.children}
diff --git a/packages/core-components/src/layout/Sidebar/Sidebar.stories.tsx b/packages/core-components/src/layout/Sidebar/Sidebar.stories.tsx index 6afd0bf157..9b5d76a32c 100644 --- a/packages/core-components/src/layout/Sidebar/Sidebar.stories.tsx +++ b/packages/core-components/src/layout/Sidebar/Sidebar.stories.tsx @@ -13,36 +13,41 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - +import { createRouteRef } from '@backstage/core-plugin-api'; +import { wrapInTestApp } from '@backstage/test-utils'; import AddCircleOutlineIcon from '@material-ui/icons/AddCircleOutline'; import HomeOutlinedIcon from '@material-ui/icons/HomeOutlined'; +import MenuIcon from '@material-ui/icons/Menu'; import BuildRoundedIcon from '@material-ui/icons/BuildRounded'; -import React from 'react'; -import { MemoryRouter } from 'react-router-dom'; +import MenuBookIcon from '@material-ui/icons/MenuBook'; +import CloudQueueIcon from '@material-ui/icons/CloudQueue'; +import AcUnitIcon from '@material-ui/icons/AcUnit'; +import AppsIcon from '@material-ui/icons/Apps'; +import React, { ComponentType } from 'react'; import { Sidebar, SidebarDivider, + SidebarGroup, SidebarExpandButton, SidebarIntro, SidebarItem, SidebarPage, SidebarSearchField, SidebarSpace, + SidebarSubmenu, + SidebarSubmenuItem, } from '.'; -import { SidebarSubmenuItem } from './SidebarSubmenuItem'; -import MenuBookIcon from '@material-ui/icons/MenuBook'; -import CloudQueueIcon from '@material-ui/icons/CloudQueue'; -import AppsIcon from '@material-ui/icons/Apps'; -import AcUnitIcon from '@material-ui/icons/AcUnit'; -import { SidebarSubmenu } from './SidebarSubmenu'; + +const routeRef = createRouteRef({ + id: 'storybook.test-route', +}); export default { title: 'Layout/Sidebar', component: Sidebar, decorators: [ - (storyFn: () => JSX.Element) => ( - {storyFn()} - ), + (Story: ComponentType<{}>) => + wrapInTestApp(, { mountedRoutes: { '/': routeRef } }), ], }; @@ -54,13 +59,15 @@ const handleSearch = (input: string) => { export const SampleSidebar = () => ( - - - - - - - + + + + + + + + + ); @@ -70,30 +77,32 @@ export const SampleScalableSidebar = () => ( - - - - - - - - - - + }> + + + + + + + + + + + diff --git a/packages/core-components/src/layout/Sidebar/SidebarGroup.test.tsx b/packages/core-components/src/layout/Sidebar/SidebarGroup.test.tsx new file mode 100644 index 0000000000..b8f1eb5ebc --- /dev/null +++ b/packages/core-components/src/layout/Sidebar/SidebarGroup.test.tsx @@ -0,0 +1,72 @@ +/* + * 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 { mockBreakpoint, renderInTestApp } from '@backstage/test-utils'; +import HomeIcon from '@material-ui/icons/Home'; +import LayersIcon from '@material-ui/icons/Layers'; +import LibraryBooks from '@material-ui/icons/LibraryBooks'; +import { fireEvent } from '@testing-library/react'; +import React from 'react'; +import { MobileSidebarContext } from './MobileSidebar'; +import { SidebarGroup, SidebarItem, SidebarPage } from '.'; + +const SidebarGroupWithItems = () => ( + + } label="Menu"> + + + + + +); + +describe('', () => { + it('should render Items in BottomNavigationAction on small screens', async () => { + mockBreakpoint({ matches: true }); + const { getByRole, getAllByRole } = await renderInTestApp( + , + ); + expect(getAllByRole('button').length).toEqual(1); + expect(getByRole('button')).toBeVisible(); + }); + + it('should render Items without wrapper on bigger screens', async () => { + mockBreakpoint({ matches: false }); + const { getByRole, queryByRole } = await renderInTestApp( + , + ); + expect(queryByRole('button')).not.toBeInTheDocument(); + expect(getByRole('link', { name: 'Home' })).toBeVisible(); + expect(getByRole('link', { name: 'Explore' })).toBeVisible(); + expect(getByRole('link', { name: 'Docs' })).toBeVisible(); + }); + + it('should trigger update of MobileSidebarContext', async () => { + mockBreakpoint({ matches: true }); + const value = { + selectedMenuItemIndex: -1, + setSelectedMenuItemIndex: jest.fn(), + }; + const { findByRole } = await renderInTestApp( + + + , + ); + const group = await findByRole('button'); + fireEvent.click(group); + expect(value.setSelectedMenuItemIndex).toHaveBeenCalled(); + }); +}); diff --git a/packages/core-components/src/layout/Sidebar/SidebarGroup.tsx b/packages/core-components/src/layout/Sidebar/SidebarGroup.tsx new file mode 100644 index 0000000000..bc7688153a --- /dev/null +++ b/packages/core-components/src/layout/Sidebar/SidebarGroup.tsx @@ -0,0 +1,130 @@ +/* eslint-disable @typescript-eslint/no-shadow */ +/* + * 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 { BackstageTheme } from '@backstage/theme'; +import BottomNavigationAction, { + BottomNavigationActionProps, +} from '@material-ui/core/BottomNavigationAction'; +import { makeStyles } from '@material-ui/core/styles'; +import React, { useContext } from 'react'; +import { useLocation } from 'react-router-dom'; +import { SidebarPinStateContext } from '.'; +import { Link } from '../../components'; +import { sidebarConfig } from './config'; +import { MobileSidebarContext } from './MobileSidebar'; + +/** + * Props for the `SidebarGroup` + * + * @public + */ +export interface SidebarGroupProps extends BottomNavigationActionProps { + /** + * If the `SidebarGroup` should be a `Link`, `to` should be a pathname to that location + */ + to?: string; + /** + * If the `SidebarGroup`s should be in a different order than in the normal `Sidebar`, you can provide + * each `SidebarGroup` it's own priority to reorder them. + */ + priority?: number; + /** + * React children + */ + children?: React.ReactNode; +} + +const useStyles = makeStyles(theme => ({ + root: { + flexGrow: 0, + margin: theme.spacing(0, 2), + color: theme.palette.navigation.color, + }, + + selected: { + color: `${theme.palette.navigation.selectedColor}!important`, + borderTop: `solid ${sidebarConfig.selectedIndicatorWidth}px ${theme.palette.navigation.indicator}`, + marginTop: '-1px', + }, + + label: { + display: 'none', + }, +})); + +/** + * Returns a MUI `BottomNavigationAction`, which is aware of the current location & the selected item in the `BottomNavigation`, + * such that it will highlight a `MobileSidebarGroup` either on location change or if the selected item changes. + * + * @param props `to`: pathname of link; `value`: index of the selected item + * @internal + */ +const MobileSidebarGroup = (props: SidebarGroupProps) => { + const { to, label, icon, value } = props; + const classes = useStyles(); + const location = useLocation(); + const { selectedMenuItemIndex, setSelectedMenuItemIndex } = + useContext(MobileSidebarContext); + + const onChange = (_: React.ChangeEvent<{}>, value: number) => { + if (value === selectedMenuItemIndex) { + setSelectedMenuItemIndex(-1); + } else { + setSelectedMenuItemIndex(value); + } + }; + + const selected = + (value === selectedMenuItemIndex && selectedMenuItemIndex >= 0) || + (!(value === selectedMenuItemIndex) && + !(selectedMenuItemIndex >= 0) && + to === location.pathname); + + return ( + // Material UI issue: https://github.com/mui-org/material-ui/issues/27820 + // @ts-ignore + + ); +}; + +/** + * Groups items of the `Sidebar` together. + * + * On bigger screens, this won't have any effect at the moment. + * On small screens, it will add an action to the bottom navigation - either triggering an overlay menu or acting as a link + * + * @public + */ +export const SidebarGroup = (props: SidebarGroupProps) => { + const { children, to, label, icon, value } = props; + const { isMobile } = useContext(SidebarPinStateContext); + + return isMobile ? ( + + ) : ( + <>{children} + ); +}; diff --git a/packages/core-components/src/layout/Sidebar/SidebarSubmenu.tsx b/packages/core-components/src/layout/Sidebar/SidebarSubmenu.tsx index 029c3386d2..60175e2439 100644 --- a/packages/core-components/src/layout/Sidebar/SidebarSubmenu.tsx +++ b/packages/core-components/src/layout/Sidebar/SidebarSubmenu.tsx @@ -38,7 +38,13 @@ const useStyles = (props: { left: number }) => flexFlow: 'column nowrap', alignItems: 'flex-start', position: 'fixed', - left: props.left, + [theme.breakpoints.up('sm')]: { + marginLeft: props.left, + transition: theme.transitions.create('margin-left', { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.shortest, + }), + }, top: 0, bottom: 0, padding: 0, @@ -58,12 +64,22 @@ const useStyles = (props: { left: number }) => }, drawerOpen: { width: submenuConfig.drawerWidthOpen, + [theme.breakpoints.down('xs')]: { + width: '100%', + position: 'relative', + paddingLeft: theme.spacing(3), + left: 0, + top: 0, + }, }, title: { fontSize: 24, fontWeight: 500, color: '#FFF', padding: 20, + [theme.breakpoints.down('xs')]: { + display: 'none', + }, }, })); @@ -88,7 +104,7 @@ export const SidebarSubmenu = (props: SidebarSubmenuProps) => { const left = isOpen ? sidebarConfig.drawerWidthOpen : sidebarConfig.drawerWidthClosed; - const classes = useStyles({ left: left })(); + const classes = useStyles({ left })(); const { isHoveredOn } = useContext(SidebarItemWithSubmenuContext); const [isSubmenuOpen, setIsSubmenuOpen] = useState(false); diff --git a/packages/core-components/src/layout/Sidebar/SidebarSubmenuItem.tsx b/packages/core-components/src/layout/Sidebar/SidebarSubmenuItem.tsx index 6efd6895b0..1ede5f887e 100644 --- a/packages/core-components/src/layout/Sidebar/SidebarSubmenuItem.tsx +++ b/packages/core-components/src/layout/Sidebar/SidebarSubmenuItem.tsx @@ -77,6 +77,10 @@ const useStyles = makeStyles(theme => ({ color: theme.palette.navigation.color, display: 'flex', justifyContent: 'center', + [theme.breakpoints.down('xs')]: { + display: 'block', + paddingLeft: theme.spacing(4), + }, fontSize: '14px', }, })); @@ -139,6 +143,7 @@ export const SidebarSubmenuItem = (props: SidebarSubmenuItemProps) => {