@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
<Sidebar>
|
||||
<SidebarLogo />
|
||||
+ <SidebarGroup label="Search" icon={<SearchIcon />} to="/search">
|
||||
<SidebarSearchModal />
|
||||
+ </SidebarGroup>
|
||||
<SidebarDivider />
|
||||
+ <SidebarGroup label="Menu" icon={<MenuIcon />}>
|
||||
<SidebarItem icon={HomeIcon} to="catalog" text="Home" />
|
||||
<SidebarItem icon={CreateComponentIcon} to="create" text="Create..." />
|
||||
<SidebarDivider />
|
||||
<SidebarScrollWrapper>
|
||||
<SidebarItem icon={MapIcon} to="tech-radar" text="Tech Radar" />
|
||||
</SidebarScrollWrapper>
|
||||
+ </SidebarGroup>
|
||||
<SidebarSpace />
|
||||
<SidebarDivider />
|
||||
+ <SidebarGroup
|
||||
+ label="Settings"
|
||||
+ icon={<UserSettingsSignInAvatar />}
|
||||
+ to="/settings"
|
||||
+ >
|
||||
<SidebarSettings />
|
||||
+ </SidebarGroup>
|
||||
</Sidebar>
|
||||
```
|
||||
|
||||
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
|
||||
<SidebarGroup
|
||||
label="Settings"
|
||||
icon={<UserSettingsSignInAvatar />}
|
||||
to="/settings"
|
||||
+ priority={1}
|
||||
>
|
||||
<SidebarSettings />
|
||||
</SidebarGroup>
|
||||
```
|
||||
|
||||
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`.
|
||||
@@ -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
|
||||
<SidebarGroup icon={<MenuIcon />} label="Menu">
|
||||
...
|
||||
<SidebarItem icon={ExtensionIcon} to="api-docs" text="APIs" />
|
||||
...
|
||||
<SidebarGroup />
|
||||
```
|
||||
|
||||
```ts
|
||||
// Example 2
|
||||
<SidebarGroup label="Search" icon={<SearchIcon />} to="/search">
|
||||
...
|
||||
<SidebarItem icon={ExtensionIcon} to="api-docs" text="APIs" />
|
||||
...
|
||||
<SidebarGroup />
|
||||
```
|
||||
|
||||
If no `SidebarGroup` is provided a default menu will display the `Sidebar`
|
||||
content.
|
||||
|
||||
@@ -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<{}>) => (
|
||||
<SidebarPage>
|
||||
<Sidebar>
|
||||
<SidebarLogo />
|
||||
<SearchContextProvider>
|
||||
<SidebarSearchModal />
|
||||
</SearchContextProvider>
|
||||
<SidebarGroup label="Search" icon={<SearchIcon />} to="/search">
|
||||
<SearchContextProvider>
|
||||
<SidebarSearchModal />
|
||||
</SearchContextProvider>
|
||||
</SidebarGroup>
|
||||
<SidebarDivider />
|
||||
{/* Global nav, not org-specific */}
|
||||
<SidebarItem icon={HomeIcon} to="catalog" text="Home" />
|
||||
<SidebarItem icon={ExtensionIcon} to="api-docs" text="APIs" />
|
||||
<SidebarItem icon={LibraryBooks} to="docs" text="Docs" />
|
||||
<SidebarItem icon={LayersIcon} to="explore" text="Explore" />
|
||||
<SidebarItem icon={CreateComponentIcon} to="create" text="Create..." />
|
||||
{/* End global nav */}
|
||||
<SidebarDivider />
|
||||
<SidebarScrollWrapper>
|
||||
<SidebarItem icon={MapIcon} to="tech-radar" text="Tech Radar" />
|
||||
<SidebarItem icon={RuleIcon} to="lighthouse" text="Lighthouse" />
|
||||
<SidebarItem icon={MoneyIcon} to="cost-insights" text="Cost Insights" />
|
||||
<SidebarItem icon={GraphiQLIcon} to="graphiql" text="GraphiQL" />
|
||||
<SidebarItem
|
||||
icon={AzurePullRequestsIcon}
|
||||
to="azure-pull-requests"
|
||||
text="Azure PRs"
|
||||
/>
|
||||
</SidebarScrollWrapper>
|
||||
<SidebarDivider />
|
||||
<Shortcuts />
|
||||
<SidebarGroup label="Menu" icon={<MenuIcon />}>
|
||||
{/* Global nav, not org-specific */}
|
||||
<SidebarItem icon={HomeIcon} to="catalog" text="Home" />
|
||||
<SidebarItem icon={ExtensionIcon} to="api-docs" text="APIs" />
|
||||
<SidebarItem icon={LibraryBooks} to="docs" text="Docs" />
|
||||
<SidebarItem icon={LayersIcon} to="explore" text="Explore" />
|
||||
<SidebarItem icon={CreateComponentIcon} to="create" text="Create..." />
|
||||
{/* End global nav */}
|
||||
<SidebarDivider />
|
||||
<SidebarScrollWrapper>
|
||||
<SidebarItem icon={MapIcon} to="tech-radar" text="Tech Radar" />
|
||||
<SidebarItem icon={RuleIcon} to="lighthouse" text="Lighthouse" />
|
||||
<SidebarItem
|
||||
icon={MoneyIcon}
|
||||
to="cost-insights"
|
||||
text="Cost Insights"
|
||||
/>
|
||||
<SidebarItem icon={GraphiQLIcon} to="graphiql" text="GraphiQL" />
|
||||
</SidebarScrollWrapper>
|
||||
<SidebarDivider />
|
||||
<Shortcuts />
|
||||
</SidebarGroup>
|
||||
<SidebarSpace />
|
||||
<SidebarDivider />
|
||||
<SidebarSettings />
|
||||
<SidebarGroup
|
||||
label="Settings"
|
||||
icon={<UserSettingsSignInAvatar />}
|
||||
to="/settings"
|
||||
>
|
||||
<SidebarSettings />
|
||||
</SidebarGroup>
|
||||
</Sidebar>
|
||||
{children}
|
||||
</SidebarPage>
|
||||
|
||||
@@ -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 (
|
||||
<Page themeId="home">
|
||||
<Header title="Search" subtitle={<Lifecycle alpha />} />
|
||||
{!isMobile && <Header title="Search" subtitle={<Lifecycle alpha />} />}
|
||||
<Content>
|
||||
<Grid container direction="row">
|
||||
<Grid item xs={12}>
|
||||
@@ -62,37 +65,39 @@ const SearchPage = () => {
|
||||
<SearchBar debounceTime={100} />
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid item xs={3}>
|
||||
<SearchType.Accordion
|
||||
name="Result Type"
|
||||
defaultValue="software-catalog"
|
||||
types={[
|
||||
{
|
||||
value: 'software-catalog',
|
||||
name: 'Software Catalog',
|
||||
icon: <CatalogIcon />,
|
||||
},
|
||||
{
|
||||
value: 'techdocs',
|
||||
name: 'Documentation',
|
||||
icon: <DocsIcon />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Paper className={classes.filters}>
|
||||
<SearchFilter.Select
|
||||
className={classes.filter}
|
||||
name="kind"
|
||||
values={['Component', 'Template']}
|
||||
{!isMobile && (
|
||||
<Grid item xs={3}>
|
||||
<SearchType.Accordion
|
||||
name="Result Type"
|
||||
defaultValue="software-catalog"
|
||||
types={[
|
||||
{
|
||||
value: 'software-catalog',
|
||||
name: 'Software Catalog',
|
||||
icon: <CatalogIcon />,
|
||||
},
|
||||
{
|
||||
value: 'techdocs',
|
||||
name: 'Documentation',
|
||||
icon: <DocsIcon />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<SearchFilter.Checkbox
|
||||
className={classes.filter}
|
||||
name="lifecycle"
|
||||
values={['experimental', 'production']}
|
||||
/>
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid item xs={9}>
|
||||
<Paper className={classes.filters}>
|
||||
<SearchFilter.Select
|
||||
className={classes.filter}
|
||||
name="kind"
|
||||
values={['Component', 'Template']}
|
||||
/>
|
||||
<SearchFilter.Checkbox
|
||||
className={classes.filter}
|
||||
name="lifecycle"
|
||||
values={['experimental', 'production']}
|
||||
/>
|
||||
</Paper>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid item xs>
|
||||
<SearchResult>
|
||||
{({ results }) => (
|
||||
<List>
|
||||
|
||||
@@ -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<Props_17>): 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<Props_17>): 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<SidebarContextType>;
|
||||
|
||||
// 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<any>
|
||||
>;
|
||||
|
||||
// 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<SidebarPinStateContextType>;
|
||||
|
||||
// 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<Props_19>): JSX.Element;
|
||||
export function TabbedCard(props: PropsWithChildren<Props_18>): 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)
|
||||
//
|
||||
|
||||
@@ -20,7 +20,7 @@ import { makeStyles, ThemeProvider } from '@material-ui/core/styles';
|
||||
|
||||
export type PageClassKey = 'root';
|
||||
|
||||
const useStyles = makeStyles(
|
||||
const useStyles = makeStyles<BackstageTheme>(
|
||||
() => ({
|
||||
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',
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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(
|
||||
<SidebarPinStateContext.Provider
|
||||
value={{ isPinned: false, toggleSidebarPinState: () => {} }}
|
||||
value={{
|
||||
isPinned: false,
|
||||
isMobile: false,
|
||||
toggleSidebarPinState: () => {},
|
||||
}}
|
||||
>
|
||||
<Sidebar disableExpandOnHover>
|
||||
<SidebarSearchField onSearch={() => {}} to="/search" />
|
||||
|
||||
@@ -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<BackstageTheme>(
|
||||
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<BackstageTheme>(
|
||||
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<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', {
|
||||
@@ -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<Props>) {
|
||||
/**
|
||||
* 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<BackstageTheme>(theme =>
|
||||
theme.breakpoints.down('md'),
|
||||
const isSmallScreen = useMediaQuery<BackstageTheme>(
|
||||
theme => theme.breakpoints.down('md'),
|
||||
{ noSsr: true },
|
||||
);
|
||||
const [state, setState] = useState(State.Closed);
|
||||
const hoverTimerRef = useRef<number>();
|
||||
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<Props>) {
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (isPinned) {
|
||||
if (isPinned || disableExpandOnHover) {
|
||||
return;
|
||||
}
|
||||
if (hoverTimerRef.current) {
|
||||
@@ -148,73 +144,60 @@ export function Sidebar(props: PropsWithChildren<Props>) {
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={classes.root}
|
||||
data-testid="sidebar-root"
|
||||
onMouseEnter={disableExpandOnHover ? () => {} : handleOpen}
|
||||
onFocus={disableExpandOnHover ? () => {} : handleOpen}
|
||||
onMouseLeave={disableExpandOnHover ? () => {} : handleClose}
|
||||
onBlur={disableExpandOnHover ? () => {} : handleClose}
|
||||
<SidebarContext.Provider
|
||||
value={{
|
||||
isOpen,
|
||||
setOpen,
|
||||
}}
|
||||
>
|
||||
<SidebarContext.Provider
|
||||
value={{
|
||||
isOpen,
|
||||
setOpen,
|
||||
}}
|
||||
<div
|
||||
onMouseEnter={handleOpen}
|
||||
onFocus={handleOpen}
|
||||
onMouseLeave={handleClose}
|
||||
onBlur={handleClose}
|
||||
data-testid="sidebar-root"
|
||||
className={classnames(classes.drawer, {
|
||||
[classes.drawerOpen]: isOpen,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={classnames(classes.drawer, {
|
||||
[classes.drawerOpen]: isOpen,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</SidebarContext.Provider>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<BackstageTheme>(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 (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={classes.expandButton}
|
||||
aria-label="Expand Sidebar"
|
||||
data-testid="sidebar-expand-button"
|
||||
return isMobile ? (
|
||||
<MobileSidebar>{children}</MobileSidebar>
|
||||
) : (
|
||||
<DesktopSidebar
|
||||
openDelayMs={openDelayMs}
|
||||
closeDelayMs={closeDelayMs}
|
||||
disableExpandOnHover={disableExpandOnHover}
|
||||
>
|
||||
<div className={classes.arrows}>
|
||||
{isOpen ? <DoubleArrowLeft /> : <DoubleArrowRight />}
|
||||
</div>
|
||||
</button>
|
||||
{children}
|
||||
</DesktopSidebar>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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<BackstageTheme>(
|
||||
@@ -83,7 +97,7 @@ const useStyles = makeStyles<BackstageTheme>(
|
||||
buttonItem: {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
width: 'auto',
|
||||
width: '100%',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
textAlign: 'inherit',
|
||||
@@ -94,7 +108,9 @@ const useStyles = makeStyles<BackstageTheme>(
|
||||
justifyContent: 'center',
|
||||
},
|
||||
open: {
|
||||
width: drawerWidthOpen,
|
||||
[theme.breakpoints.up('sm')]: {
|
||||
width: drawerWidthOpen,
|
||||
},
|
||||
},
|
||||
highlightable: {
|
||||
'&:hover': {
|
||||
@@ -134,7 +150,7 @@ const useStyles = makeStyles<BackstageTheme>(
|
||||
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<BackstageTheme>(
|
||||
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<BackstageTheme>(
|
||||
{ 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<SidebarItemWithSubmenuProps>) => {
|
||||
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 = (
|
||||
<Badge
|
||||
color="secondary"
|
||||
variant="dot"
|
||||
overlap="circular"
|
||||
className={isOpen ? '' : classes.closedItemIcon}
|
||||
invisible={!hasNotifications}
|
||||
>
|
||||
<Icon fontSize="small" />
|
||||
</Badge>
|
||||
/**
|
||||
* 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<SidebarSubmenuProps>,
|
||||
location: Location,
|
||||
): boolean =>
|
||||
useElementFilter(
|
||||
submenu.props.children,
|
||||
elements => {
|
||||
let active = false;
|
||||
elements
|
||||
.getElements()
|
||||
.forEach(
|
||||
({
|
||||
props: { to, dropdownItems },
|
||||
}: {
|
||||
props: Partial<SidebarSubmenuItemProps>;
|
||||
}) => {
|
||||
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 = (
|
||||
<>
|
||||
<div data-testid="login-button" className={classes.iconContainer}>
|
||||
{itemIcon}
|
||||
</div>
|
||||
{text && (
|
||||
<Typography variant="subtitle2" className={classes.label}>
|
||||
{text}
|
||||
</Typography>
|
||||
)}
|
||||
<div className={classes.secondaryAction}>{}</div>
|
||||
</>
|
||||
);
|
||||
const closedContent = itemIcon;
|
||||
|
||||
return (
|
||||
<SidebarItemWithSubmenuContext.Provider
|
||||
value={{
|
||||
isHoveredOn,
|
||||
setIsHoveredOn,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={classnames(isHoveredOn && classes.highlighted)}
|
||||
>
|
||||
<div
|
||||
onMouseEnter={handleMouseEnter}
|
||||
data-testid="item-with-submenu"
|
||||
className={classnames(
|
||||
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;
|
||||
@@ -370,7 +335,10 @@ export const WorkaroundNavLink = React.forwardRef<
|
||||
);
|
||||
});
|
||||
|
||||
export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
|
||||
/**
|
||||
* Common component used by SidebarItem & SidebarItemWithSubmenu
|
||||
*/
|
||||
const SidebarItemBase = forwardRef<any, SidebarItemProps>((props, ref) => {
|
||||
const {
|
||||
icon: Icon,
|
||||
text,
|
||||
@@ -399,8 +367,6 @@ export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
|
||||
</Badge>
|
||||
);
|
||||
|
||||
const closedContent = itemIcon;
|
||||
|
||||
const openContent = (
|
||||
<>
|
||||
<div data-testid="login-button" className={classes.iconContainer}>
|
||||
@@ -415,7 +381,7 @@ export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
|
||||
</>
|
||||
);
|
||||
|
||||
const content = isOpen ? openContent : closedContent;
|
||||
const content = isOpen ? openContent : itemIcon;
|
||||
|
||||
const childProps = {
|
||||
onClick,
|
||||
@@ -428,39 +394,6 @@ export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
|
||||
),
|
||||
};
|
||||
|
||||
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}>
|
||||
@@ -483,6 +416,90 @@ export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
|
||||
);
|
||||
});
|
||||
|
||||
const SidebarItemWithSubmenu = ({
|
||||
children,
|
||||
...props
|
||||
}: SidebarItemBaseProps & {
|
||||
children: React.ReactElement<SidebarSubmenuProps>;
|
||||
}) => {
|
||||
const classes = useStyles();
|
||||
const [isHoveredOn, setIsHoveredOn] = useState(false);
|
||||
const location = useLocation();
|
||||
const isActive = useLocationMatch(children, location);
|
||||
const isSmallScreen = useMediaQuery<BackstageTheme>((theme: BackstageTheme) =>
|
||||
theme.breakpoints.down('sm'),
|
||||
);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setIsHoveredOn(true);
|
||||
};
|
||||
const handleMouseLeave = () => {
|
||||
setIsHoveredOn(false);
|
||||
};
|
||||
|
||||
const arrowIcon = () => {
|
||||
if (isSmallScreen) {
|
||||
return isHoveredOn ? (
|
||||
<ArrowDropUp fontSize="small" className={classes.submenuArrow} />
|
||||
) : (
|
||||
<ArrowDropDown fontSize="small" className={classes.submenuArrow} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
!isHoveredOn && (
|
||||
<ArrowRightIcon fontSize="small" className={classes.submenuArrow} />
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarItemWithSubmenuContext.Provider
|
||||
value={{
|
||||
isHoveredOn,
|
||||
setIsHoveredOn,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-testid="item-with-submenu"
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onTouchStart={isHoveredOn ? handleMouseLeave : handleMouseEnter}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
className={classnames(isHoveredOn && classes.highlighted)}
|
||||
>
|
||||
<SidebarItemBase
|
||||
className={isActive ? classes.selected : ''}
|
||||
{...props}
|
||||
>
|
||||
{arrowIcon()}
|
||||
</SidebarItemBase>
|
||||
{isHoveredOn && children}
|
||||
</div>
|
||||
</SidebarItemWithSubmenuContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a `SidebarItem`
|
||||
*
|
||||
* If children contain a `SidebarSubmenu` component the `SidebarItem` will have a expandable submenu
|
||||
*/
|
||||
export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
|
||||
// Filter children for SidebarSubmenu components
|
||||
const [submenu] = useElementFilter(props.children, elements =>
|
||||
elements.getElements().filter(child => child.type === SidebarSubmenu),
|
||||
);
|
||||
|
||||
if (submenu) {
|
||||
return (
|
||||
<SidebarItemWithSubmenu {...props}>
|
||||
{submenu as React.ReactElement<SidebarSubmenuProps>}
|
||||
</SidebarItemWithSubmenu>
|
||||
);
|
||||
}
|
||||
|
||||
return <SidebarItemBase {...props} ref={ref} />;
|
||||
});
|
||||
|
||||
type SidebarSearchFieldProps = {
|
||||
onSearch: (input: string) => void;
|
||||
to?: string;
|
||||
@@ -609,3 +626,41 @@ export const SidebarScrollWrapper = styled('div')(({ theme }) => {
|
||||
'&:hover': scrollbarStyles,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* If you are using this you might want to set the `disableExpandOnHover` of the `Sidebar` to `true`.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const SidebarExpandButton = () => {
|
||||
const classes = useStyles();
|
||||
const { isOpen, setOpen } = useContext(SidebarContext);
|
||||
const isSmallScreen = useMediaQuery<BackstageTheme>(
|
||||
theme => theme.breakpoints.down('md'),
|
||||
{ noSsr: true },
|
||||
);
|
||||
|
||||
if (isSmallScreen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
setOpen(!isOpen);
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 = () => (
|
||||
<SidebarPage>
|
||||
<MobileSidebar>
|
||||
<h1>Header</h1>
|
||||
<SidebarGroup icon={<HomeIcon />} label="Menu">
|
||||
<SidebarItem icon={HomeIcon} to="/" text="Home" />
|
||||
<SidebarItem icon={LayersIcon} to="/" text="Explore" />
|
||||
<SidebarItem icon={LibraryBooks} to="/" text="Docs" />
|
||||
</SidebarGroup>
|
||||
<div>Content</div>
|
||||
<div>More Content</div>
|
||||
<SidebarGroup icon={<CreateComponentIcon />} label="Create" to="#" />
|
||||
<footer>Footer</footer>
|
||||
</MobileSidebar>
|
||||
</SidebarPage>
|
||||
);
|
||||
|
||||
const MobileSidebarWithoutGroups = () => (
|
||||
<SidebarPage>
|
||||
<MobileSidebar>
|
||||
<SidebarItem icon={HomeIcon} to="/one" text="Home" />
|
||||
<SidebarItem icon={LayersIcon} to="/two" text="Explore" />
|
||||
<SidebarItem icon={LibraryBooks} to="/three" text="Docs" />
|
||||
</MobileSidebar>
|
||||
</SidebarPage>
|
||||
);
|
||||
|
||||
describe('<MobileSidebar />', () => {
|
||||
beforeEach(() => {
|
||||
mockBreakpoint({ matches: true });
|
||||
});
|
||||
|
||||
it('should render MobileSidebar on smaller screens', async () => {
|
||||
const { getByTestId } = await renderInTestApp(
|
||||
<SidebarPage>
|
||||
<Sidebar>
|
||||
<SidebarItem icon={HomeIcon} to="/one" text="Home" />
|
||||
</Sidebar>
|
||||
</SidebarPage>,
|
||||
);
|
||||
expect(getByTestId('mobile-sidebar-root')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should render only SidebarGroups inside MobileSidebar', async () => {
|
||||
const { findAllByRole, getByTestId, findByText } = await renderInTestApp(
|
||||
<MobileSidebarWithGroups />,
|
||||
);
|
||||
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(
|
||||
<MobileSidebarWithoutGroups />,
|
||||
);
|
||||
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(
|
||||
<MobileSidebarWithGroups />,
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<React.SetStateAction<number>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<BackstageTheme>(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 (
|
||||
<Drawer
|
||||
anchor="bottom"
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
classes={{ paperAnchorBottom: classes.overlay }}
|
||||
>
|
||||
<Box className={classes.overlayHeader}>
|
||||
<Typography variant="h3">{label}</Typography>
|
||||
<IconButton
|
||||
onClick={onClose}
|
||||
classes={{ root: classes.overlayHeaderClose }}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Box>{children}</Box>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Context on which `SidebarGroup` is currently selected
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export const MobileSidebarContext = createContext<MobileSidebarContextType>({
|
||||
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<number>(-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(
|
||||
<SidebarGroup key="default_menu" icon={<MenuIcon />}>
|
||||
{children}
|
||||
</SidebarGroup>,
|
||||
);
|
||||
} else {
|
||||
// Sort SidebarGroups for the given Priority
|
||||
sidebarGroups = sortSidebarGroupsForPriority(sidebarGroups);
|
||||
}
|
||||
|
||||
const shouldShowGroupChildren =
|
||||
selectedMenuItemIndex >= 0 &&
|
||||
!sidebarGroups[selectedMenuItemIndex].props.to;
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={{ isOpen: true, setOpen: () => {} }}>
|
||||
<MobileSidebarContext.Provider
|
||||
value={{ selectedMenuItemIndex, setSelectedMenuItemIndex }}
|
||||
>
|
||||
<OverlayMenu
|
||||
label={
|
||||
sidebarGroups[selectedMenuItemIndex] &&
|
||||
(sidebarGroups[selectedMenuItemIndex]!.props.label as string)
|
||||
}
|
||||
open={shouldShowGroupChildren}
|
||||
onClose={() => setSelectedMenuItemIndex(-1)}
|
||||
>
|
||||
{sidebarGroups[selectedMenuItemIndex] &&
|
||||
(sidebarGroups[selectedMenuItemIndex].props
|
||||
.children as React.ReactChildren)}
|
||||
</OverlayMenu>
|
||||
<BottomNavigation
|
||||
className={classes.root}
|
||||
data-testid="mobile-sidebar-root"
|
||||
>
|
||||
{sidebarGroups}
|
||||
</BottomNavigation>
|
||||
</MobileSidebarContext.Provider>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -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<BackstageTheme, { isPinned: boolean }>(
|
||||
{
|
||||
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<SidebarPinStateContextType>(
|
||||
{
|
||||
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<BackstageTheme>(
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<div className={classes.root}>{props.children}</div>
|
||||
|
||||
@@ -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) => (
|
||||
<MemoryRouter initialEntries={['/']}>{storyFn()}</MemoryRouter>
|
||||
),
|
||||
(Story: ComponentType<{}>) =>
|
||||
wrapInTestApp(<Story />, { mountedRoutes: { '/': routeRef } }),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -54,13 +59,15 @@ const handleSearch = (input: string) => {
|
||||
export const SampleSidebar = () => (
|
||||
<SidebarPage>
|
||||
<Sidebar>
|
||||
<SidebarSearchField onSearch={handleSearch} to="/search" />
|
||||
<SidebarDivider />
|
||||
<SidebarItem icon={HomeOutlinedIcon} to="#" text="Plugins" />
|
||||
<SidebarItem icon={AddCircleOutlineIcon} to="#" text="Create..." />
|
||||
<SidebarDivider />
|
||||
<SidebarIntro />
|
||||
<SidebarSpace />
|
||||
<SidebarGroup label="Menu" icon={MenuIcon}>
|
||||
<SidebarSearchField onSearch={handleSearch} to="/search" />
|
||||
<SidebarDivider />
|
||||
<SidebarItem icon={HomeOutlinedIcon} to="#" text="Plugins" />
|
||||
<SidebarItem icon={AddCircleOutlineIcon} to="#" text="Create..." />
|
||||
<SidebarDivider />
|
||||
<SidebarIntro />
|
||||
<SidebarSpace />
|
||||
</SidebarGroup>
|
||||
</Sidebar>
|
||||
</SidebarPage>
|
||||
);
|
||||
@@ -70,30 +77,32 @@ export const SampleScalableSidebar = () => (
|
||||
<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="Components" to="/3" icon={AppsIcon} />
|
||||
<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..." />
|
||||
<SidebarGroup label="Menu" icon={<MenuIcon />}>
|
||||
<SidebarItem icon={MenuBookIcon} text="Catalog">
|
||||
<SidebarSubmenu title="Catalog">
|
||||
<SidebarSubmenuItem title="Tools" to="/1" icon={BuildRoundedIcon} />
|
||||
<SidebarSubmenuItem title="APIs" to="/2" icon={CloudQueueIcon} />
|
||||
<SidebarSubmenuItem title="Components" to="/3" icon={AppsIcon} />
|
||||
<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..." />
|
||||
</SidebarGroup>
|
||||
<SidebarDivider />
|
||||
<SidebarIntro />
|
||||
<SidebarSpace />
|
||||
|
||||
@@ -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 = () => (
|
||||
<SidebarPage>
|
||||
<SidebarGroup icon={<HomeIcon />} label="Menu">
|
||||
<SidebarItem icon={HomeIcon} to="/one" text="Home" />
|
||||
<SidebarItem icon={LayersIcon} to="/two" text="Explore" />
|
||||
<SidebarItem icon={LibraryBooks} to="/three" text="Docs" />
|
||||
</SidebarGroup>
|
||||
</SidebarPage>
|
||||
);
|
||||
|
||||
describe('<SidebarGroup />', () => {
|
||||
it('should render Items in BottomNavigationAction on small screens', async () => {
|
||||
mockBreakpoint({ matches: true });
|
||||
const { getByRole, getAllByRole } = await renderInTestApp(
|
||||
<SidebarGroupWithItems />,
|
||||
);
|
||||
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(
|
||||
<SidebarGroupWithItems />,
|
||||
);
|
||||
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(
|
||||
<MobileSidebarContext.Provider value={value}>
|
||||
<SidebarGroupWithItems />
|
||||
</MobileSidebarContext.Provider>,
|
||||
);
|
||||
const group = await findByRole('button');
|
||||
fireEvent.click(group);
|
||||
expect(value.setSelectedMenuItemIndex).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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<BackstageTheme>(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
|
||||
<BottomNavigationAction
|
||||
label={label}
|
||||
icon={icon}
|
||||
component={Link}
|
||||
to={to ? to : location.pathname}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
selected={selected}
|
||||
classes={classes}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 ? (
|
||||
<MobileSidebarGroup to={to} label={label} icon={icon} value={value} />
|
||||
) : (
|
||||
<>{children}</>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -77,6 +77,10 @@ const useStyles = makeStyles<BackstageTheme>(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) => {
|
||||
<div className={classes.itemContainer}>
|
||||
<button
|
||||
onClick={handleClickDropdown}
|
||||
onTouchStart={e => e.stopPropagation()}
|
||||
className={classnames(
|
||||
classes.item,
|
||||
isActive ? classes.selected : undefined,
|
||||
@@ -163,6 +168,7 @@ export const SidebarSubmenuItem = (props: SidebarSubmenuItemProps) => {
|
||||
underline="none"
|
||||
className={classes.dropdownItem}
|
||||
onClick={closeSubmenu}
|
||||
onTouchStart={e => e.stopPropagation()}
|
||||
key={key}
|
||||
>
|
||||
<Typography className={classes.textContent}>
|
||||
@@ -187,6 +193,7 @@ export const SidebarSubmenuItem = (props: SidebarSubmenuItemProps) => {
|
||||
isActive ? classes.selected : undefined,
|
||||
)}
|
||||
onClick={closeSubmenu}
|
||||
onTouchStart={e => e.stopPropagation()}
|
||||
>
|
||||
<Icon fontSize="small" />
|
||||
<Typography variant="subtitle1" className={classes.label}>
|
||||
|
||||
@@ -35,6 +35,7 @@ export const sidebarConfig = {
|
||||
selectedIndicatorWidth: 3,
|
||||
userBadgePadding,
|
||||
userBadgeDiameter: drawerWidthClosed - userBadgePadding * 2,
|
||||
mobileSidebarHeight: 56,
|
||||
};
|
||||
|
||||
export const submenuConfig = {
|
||||
@@ -46,14 +47,20 @@ export const submenuConfig = {
|
||||
export const SIDEBAR_INTRO_LOCAL_STORAGE =
|
||||
'@backstage/core/sidebar-intro-dismissed';
|
||||
|
||||
/**
|
||||
* Types for the `SidebarContext`
|
||||
*/
|
||||
export type SidebarContextType = {
|
||||
isOpen: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Context wether the `Sidebar` is open
|
||||
*/
|
||||
export const SidebarContext = createContext<SidebarContextType>({
|
||||
isOpen: false,
|
||||
setOpen: _open => {},
|
||||
setOpen: () => {},
|
||||
});
|
||||
|
||||
export type SidebarItemWithSubmenuContextType = {
|
||||
|
||||
@@ -14,7 +14,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { Sidebar, SidebarExpandButton } from './Bar';
|
||||
export { Sidebar } from './Bar';
|
||||
export { MobileSidebar } from './MobileSidebar';
|
||||
export type { MobileSidebarProps } from './MobileSidebar';
|
||||
export { SidebarGroup } from './SidebarGroup';
|
||||
export type { SidebarGroupProps } from './SidebarGroup';
|
||||
export { SidebarSubmenuItem } from './SidebarSubmenuItem';
|
||||
export { SidebarSubmenu } from './SidebarSubmenu';
|
||||
export type { SidebarSubmenuProps } from './SidebarSubmenu';
|
||||
@@ -22,9 +26,16 @@ export type {
|
||||
SidebarSubmenuItemProps,
|
||||
SidebarSubmenuItemDropdownItem,
|
||||
} from './SidebarSubmenuItem';
|
||||
export type { SidebarClassKey } from './Bar';
|
||||
export { SidebarPage, SidebarPinStateContext } from './Page';
|
||||
export type { SidebarPinStateContextType, SidebarPageClassKey } from './Page';
|
||||
export type { SidebarClassKey, SidebarProps } from './Bar';
|
||||
export {
|
||||
SidebarPage,
|
||||
SidebarPinStateContext as SidebarPinStateContext,
|
||||
} from './Page';
|
||||
export type {
|
||||
SidebarPinStateContextType as SidebarPinStateContextType,
|
||||
SidebarPageClassKey,
|
||||
SidebarPageProps,
|
||||
} from './Page';
|
||||
export {
|
||||
SidebarDivider,
|
||||
SidebarItem,
|
||||
@@ -32,6 +43,7 @@ export {
|
||||
SidebarSpace,
|
||||
SidebarSpacer,
|
||||
SidebarScrollWrapper,
|
||||
SidebarExpandButton,
|
||||
} from './Items';
|
||||
export type {
|
||||
SidebarItemClassKey,
|
||||
|
||||
+34
-18
@@ -24,21 +24,27 @@ import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
|
||||
import LogoFull from './LogoFull';
|
||||
import LogoIcon from './LogoIcon';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { Settings as SidebarSettings } from '@backstage/plugin-user-settings';
|
||||
import {
|
||||
Settings as SidebarSettings,
|
||||
UserSettingsSignInAvatar,
|
||||
} from '@backstage/plugin-user-settings';
|
||||
import {
|
||||
SidebarSearchModal,
|
||||
SearchContextProvider,
|
||||
} from '@backstage/plugin-search';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarPage,
|
||||
sidebarConfig,
|
||||
SidebarContext,
|
||||
SidebarItem,
|
||||
SidebarDivider,
|
||||
SidebarSpace,
|
||||
SidebarGroup,
|
||||
SidebarItem,
|
||||
SidebarPage,
|
||||
SidebarScrollWrapper,
|
||||
SidebarSpace,
|
||||
} from '@backstage/core-components';
|
||||
import MenuIcon from '@material-ui/icons/Menu';
|
||||
import SearchIcon from '@material-ui/icons/Search';
|
||||
|
||||
const useSidebarLogoStyles = makeStyles({
|
||||
root: {
|
||||
@@ -77,23 +83,33 @@ export const Root = ({ children }: PropsWithChildren<{}>) => (
|
||||
<SidebarPage>
|
||||
<Sidebar>
|
||||
<SidebarLogo />
|
||||
<SearchContextProvider>
|
||||
<SidebarSearchModal />
|
||||
</SearchContextProvider>
|
||||
<SidebarGroup label="Search" icon={<SearchIcon />} to="/search">
|
||||
<SearchContextProvider>
|
||||
<SidebarSearchModal />
|
||||
</SearchContextProvider>{' '}
|
||||
</SidebarGroup>
|
||||
<SidebarDivider />
|
||||
{/* Global nav, not org-specific */}
|
||||
<SidebarItem icon={HomeIcon} to="catalog" text="Home" />
|
||||
<SidebarItem icon={ExtensionIcon} to="api-docs" text="APIs" />
|
||||
<SidebarItem icon={LibraryBooks} to="docs" text="Docs" />
|
||||
<SidebarItem icon={CreateComponentIcon} to="create" text="Create..." />
|
||||
{/* End global nav */}
|
||||
<SidebarDivider />
|
||||
<SidebarScrollWrapper>
|
||||
<SidebarItem icon={MapIcon} to="tech-radar" text="Tech Radar" />
|
||||
</SidebarScrollWrapper>
|
||||
<SidebarGroup label="Menu" icon={<MenuIcon />}>
|
||||
{/* Global nav, not org-specific */}
|
||||
<SidebarItem icon={HomeIcon} to="catalog" text="Home" />
|
||||
<SidebarItem icon={ExtensionIcon} to="api-docs" text="APIs" />
|
||||
<SidebarItem icon={LibraryBooks} to="docs" text="Docs" />
|
||||
<SidebarItem icon={CreateComponentIcon} to="create" text="Create..." />
|
||||
{/* End global nav */}
|
||||
<SidebarDivider />
|
||||
<SidebarScrollWrapper>
|
||||
<SidebarItem icon={MapIcon} to="tech-radar" text="Tech Radar" />
|
||||
</SidebarScrollWrapper>
|
||||
</SidebarGroup>
|
||||
<SidebarSpace />
|
||||
<SidebarDivider />
|
||||
<SidebarSettings />
|
||||
<SidebarGroup
|
||||
label="Settings"
|
||||
icon={<UserSettingsSignInAvatar />}
|
||||
to="/settings"
|
||||
>
|
||||
<SidebarSettings />
|
||||
</SidebarGroup>
|
||||
</Sidebar>
|
||||
{children}
|
||||
</SidebarPage>
|
||||
|
||||
@@ -31,6 +31,7 @@ const useStyles = makeStyles({
|
||||
},
|
||||
itemText: {
|
||||
width: '100%',
|
||||
wordBreak: 'break-all',
|
||||
marginBottom: '1rem',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -79,21 +79,27 @@ To expose the plugin to your users, you can integrate the `cost-insights` route
|
||||
export const AppSidebar = () => (
|
||||
<Sidebar>
|
||||
<SidebarLogo />
|
||||
<SidebarSearch />
|
||||
<SidebarGroup icon={<SearchIcon />} to="/search">
|
||||
<SidebarSearch />
|
||||
</SidebarGroup>
|
||||
<SidebarDivider />
|
||||
{/* Global nav, not org-specific */}
|
||||
<SidebarItem icon={HomeIcon} to="./" text="Home" />
|
||||
<SidebarItem icon={ExtensionIcon} to="api-docs" text="APIs" />
|
||||
<SidebarItem icon={LibraryBooks} to="/docs" text="Docs" />
|
||||
<SidebarItem icon={CreateComponentIcon} to="create" text="Create..." />
|
||||
<SidebarDivider />
|
||||
<SidebarItem icon={MapIcon} to="tech-radar" text="Tech Radar" />
|
||||
+ <SidebarItem icon={MoneyIcon} to="cost-insights" text="Cost Insights" />
|
||||
<SidebarGroup label="Menu" icon={<MenuIcon />}>
|
||||
<SidebarItem icon={HomeIcon} to="./" text="Home" />
|
||||
<SidebarItem icon={ExtensionIcon} to="api-docs" text="APIs" />
|
||||
<SidebarItem icon={LibraryBooks} to="/docs" text="Docs" />
|
||||
<SidebarItem icon={CreateComponentIcon} to="create" text="Create..." />
|
||||
<SidebarDivider />
|
||||
<SidebarItem icon={MapIcon} to="tech-radar" text="Tech Radar" />
|
||||
+ <SidebarItem icon={MoneyIcon} to="cost-insights" text="Cost Insights" />
|
||||
</SidebarGroup>
|
||||
{/* End global nav */}
|
||||
<SidebarDivider />
|
||||
<SidebarSpace />
|
||||
<SidebarDivider />
|
||||
<SidebarSettings />
|
||||
<SidebarGroup icon={<UserSettingsSignInAvatar />} to="/settings">
|
||||
<SidebarSettings />
|
||||
</SidebarGroup>
|
||||
</Sidebar>
|
||||
);
|
||||
```
|
||||
|
||||
@@ -31,7 +31,7 @@ import { alertApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
card: {
|
||||
width: 400,
|
||||
maxWidth: 400,
|
||||
},
|
||||
header: {
|
||||
marginBottom: theme.spacing(1),
|
||||
|
||||
@@ -13,17 +13,21 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { InfoCard, SidebarPinStateContext } from '@backstage/core-components';
|
||||
import { List } from '@material-ui/core';
|
||||
import { InfoCard } from '@backstage/core-components';
|
||||
import React, { useContext } from 'react';
|
||||
import { UserSettingsPinToggle } from './UserSettingsPinToggle';
|
||||
import { UserSettingsThemeToggle } from './UserSettingsThemeToggle';
|
||||
|
||||
export const UserSettingsAppearanceCard = () => (
|
||||
<InfoCard title="Appearance" variant="gridItem">
|
||||
<List dense>
|
||||
<UserSettingsThemeToggle />
|
||||
<UserSettingsPinToggle />
|
||||
</List>
|
||||
</InfoCard>
|
||||
);
|
||||
export const UserSettingsAppearanceCard = () => {
|
||||
const { isMobile } = useContext(SidebarPinStateContext);
|
||||
|
||||
return (
|
||||
<InfoCard title="Appearance" variant="gridItem">
|
||||
<List dense>
|
||||
<UserSettingsThemeToggle />
|
||||
{!isMobile && <UserSettingsPinToggle />}
|
||||
</List>
|
||||
</InfoCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,10 +21,10 @@ import { UserSettingsAppearanceCard } from './UserSettingsAppearanceCard';
|
||||
export const UserSettingsGeneral = () => {
|
||||
return (
|
||||
<Grid container direction="row" spacing={3}>
|
||||
<Grid item sm={12} md={6}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<UserSettingsProfileCard />
|
||||
</Grid>
|
||||
<Grid item sm={12} md={6}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<UserSettingsAppearanceCard />
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
@@ -26,7 +26,11 @@ describe('<UserSettingsPinToggle />', () => {
|
||||
const rendered = await renderWithEffects(
|
||||
wrapInTestApp(
|
||||
<SidebarPinStateContext.Provider
|
||||
value={{ isPinned: false, toggleSidebarPinState: mockToggleFn }}
|
||||
value={{
|
||||
isPinned: false,
|
||||
isMobile: false,
|
||||
toggleSidebarPinState: mockToggleFn,
|
||||
}}
|
||||
>
|
||||
<UserSettingsPinToggle />
|
||||
</SidebarPinStateContext.Provider>,
|
||||
|
||||
@@ -14,21 +14,27 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Header,
|
||||
Page,
|
||||
SidebarPinStateContext,
|
||||
TabbedLayout,
|
||||
} from '@backstage/core-components';
|
||||
import React, { useContext } from 'react';
|
||||
import { UserSettingsAuthProviders } from './AuthProviders';
|
||||
import { UserSettingsFeatureFlags } from './FeatureFlags';
|
||||
import { UserSettingsGeneral } from './General';
|
||||
import { Header, Page, TabbedLayout } from '@backstage/core-components';
|
||||
|
||||
type Props = {
|
||||
providerSettings?: JSX.Element;
|
||||
};
|
||||
|
||||
export const SettingsPage = ({ providerSettings }: Props) => {
|
||||
const { isMobile } = useContext(SidebarPinStateContext);
|
||||
|
||||
return (
|
||||
<Page themeId="home">
|
||||
<Header title="Settings" />
|
||||
|
||||
{!isMobile && <Header title="Settings" />}
|
||||
<TabbedLayout>
|
||||
<TabbedLayout.Route path="general" title="General">
|
||||
<UserSettingsGeneral />
|
||||
|
||||
Reference in New Issue
Block a user