add LayoutProvider

Signed-off-by: Juan Pablo Garcia Ripa <sarabadu@gmail.com>
This commit is contained in:
Juan Pablo Garcia Ripa
2021-09-19 00:59:45 +02:00
parent be5b5e3eba
commit 8bb0f4bc8a
10 changed files with 263 additions and 23 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': patch
---
Add SidebarPageContent component and useContenRef hook to have a reference to content wrapper element
+22
View File
@@ -1243,6 +1243,21 @@ export type SidebarPageProps = {
};
// @public
// Warning: (ae-missing-release-tag) "SidebarPageContent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export function SidebarPageContent(props: PropsWithChildren<{}>): JSX.Element;
// Warning: (ae-missing-release-tag) "SidebarPageContextType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type SidebarPageContextType = {
contentRef?: React_2.MutableRefObject<HTMLDivElement | null>;
};
// 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)
export const SidebarPinStateContext: React_2.Context<SidebarPinStateContextType>;
// @public
@@ -2404,6 +2419,13 @@ export function TrendLine(
},
): JSX.Element | null;
// Warning: (ae-missing-release-tag) "useContentRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export function useContentRef():
| React_2.MutableRefObject<HTMLDivElement | null>
| undefined;
// Warning: (ae-forgotten-export) The symbol "SetQueryParams" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "useQueryParamState" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -17,6 +17,7 @@
import { makeStyles, Theme } from '@material-ui/core/styles';
import classNames from 'classnames';
import React, { PropsWithChildren } from 'react';
import { useLayoutContent } from '../LayoutProvider/LayoutProvider';
/** @public */
export type BackstageContentClassKey = 'root' | 'stretch' | 'noPadding';
@@ -62,9 +63,14 @@ type Props = {
export function Content(props: PropsWithChildren<Props>) {
const { className, stretch, noPadding, children, ...restProps } = props;
const { contentRef } = useLayoutContent();
const classes = useStyles();
return (
<article
ref={contentRef}
tabIndex={-1}
{...restProps}
className={classNames(classes.root, className, {
[classes.stretch]: stretch,
@@ -0,0 +1,58 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, {
createContext,
PropsWithChildren,
useContext,
useRef,
} from 'react';
export type LayoutContextType = {
content: {
contentRef?: React.MutableRefObject<HTMLDivElement | null>;
};
};
const LayoutContext = createContext<LayoutContextType>({
content: {
contentRef: undefined,
},
});
export function LayoutProvider(props: PropsWithChildren<{}>) {
const contentRef = useRef(null);
const content = {
contentRef,
};
return (
<LayoutContext.Provider value={{ content }}>
{props.children}
</LayoutContext.Provider>
);
}
export function useLayoutContent() {
const { content } = useContext(LayoutContext);
const focusContent = () => {
content.contentRef?.current?.focus();
};
return { focusContent, contentRef: content.contentRef };
}
@@ -0,0 +1,16 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { LayoutProvider, useLayoutContent } from './LayoutProvider';
@@ -17,11 +17,17 @@
import { makeStyles } from '@material-ui/core/styles';
import useMediaQuery from '@material-ui/core/useMediaQuery';
import classnames from 'classnames';
import React, { useState, useContext, useRef } from 'react';
import React, { useState, useContext, PropsWithChildren, useRef } from 'react';
import Button from '@material-ui/core/Button';
import { sidebarConfig, SidebarContext } from './config';
import { BackstageTheme } from '@backstage/theme';
import { SidebarPinStateContext } from './Page';
import { MobileSidebar } from './MobileSidebar';
import DoubleArrowRight from './icons/DoubleArrowRight';
import DoubleArrowLeft from './icons/DoubleArrowLeft';
import { useLayoutContent } from '../LayoutProvider/LayoutProvider';
/** @public */
export type SidebarClassKey = 'drawer' | 'drawerOpen';
@@ -60,6 +66,15 @@ const useStyles = makeStyles<BackstageTheme>(
duration: theme.transitions.duration.shorter,
}),
},
visuallyHidden: {
top: 0,
position: 'absolute',
zIndex: 2,
transform: 'translateY(-200%)',
'&:focus': {
transform: 'translateY(5px)',
},
},
}),
{ name: 'BackstageSidebar' },
);
@@ -105,6 +120,7 @@ const DesktopSidebar = (props: SidebarProps) => {
const { isPinned, toggleSidebarPinState } = useContext(
SidebarPinStateContext,
);
const { focusContent } = useLayoutContent();
const handleOpen = () => {
if (isPinned || disableExpandOnHover) {
@@ -128,6 +144,7 @@ const DesktopSidebar = (props: SidebarProps) => {
if (isPinned || disableExpandOnHover) {
return;
}
focusContent();
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
hoverTimerRef.current = undefined;
@@ -158,25 +175,42 @@ const DesktopSidebar = (props: SidebarProps) => {
};
return (
<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 style={{}}>
<Button
onClick={focusContent}
variant="contained"
className={classnames(classes.visuallyHidden)}
>
{children}
</div>
</SidebarContext.Provider>
Skip to content
</Button>
<SidebarContext.Provider
value={{
isOpen,
setOpen,
}}
>
<div
className={classes.root}
data-testid="sidebar-root"
onMouseEnter={disableExpandOnHover ? () => {} : handleOpen}
onFocus={
disableExpandOnHover ? () => {} : ignoreChildEvent(handleOpen)
}
onMouseLeave={disableExpandOnHover ? () => {} : handleClose}
onBlur={
disableExpandOnHover ? () => {} : ignoreChildEvent(handleClose)
}
>
<div
className={classnames(classes.drawer, {
[classes.drawerOpen]: isOpen,
})}
>
{children}
</div>
</div>
</SidebarContext.Provider>
</div>
);
};
@@ -201,3 +235,12 @@ export const Sidebar = (props: SidebarProps) => {
</DesktopSidebar>
);
};
function ignoreChildEvent(handlerFn: (e?: any) => void) {
// TODO type the event
return (event: any) => {
const currentTarget = event?.currentTarget as HTMLElement;
if (!currentTarget?.contains(event.relatedTarget as HTMLElement)) {
handlerFn(event);
}
};
}
@@ -15,7 +15,14 @@
*/
import { makeStyles } from '@material-ui/core/styles';
import React, { createContext, useEffect, useState } from 'react';
import React, {
createContext,
PropsWithChildren,
useEffect,
useState,
} from 'react';
import { sidebarConfig } from './config';
import { BackstageTheme } from '@backstage/theme';
import { LocalStorage } from './localStorage';
@@ -28,6 +35,7 @@ const useStyles = makeStyles<BackstageTheme, { isPinned: boolean }>(
root: {
width: '100%',
transition: 'padding-left 0.1s ease-out',
isolation: 'isolate',
[theme.breakpoints.up('sm')]: {
paddingLeft: ({ isPinned }) =>
isPinned
@@ -38,6 +46,13 @@ const useStyles = makeStyles<BackstageTheme, { isPinned: boolean }>(
paddingBottom: sidebarConfig.mobileSidebarHeight,
},
},
content: {
zIndex: 0,
isolation: 'isolate',
'&:focus': {
outline: 0,
},
},
}),
{ name: 'BackstageSidebarPage' },
);
@@ -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 AddCircleOutlineIcon from '@material-ui/icons/AddCircleOutline';
import HomeOutlinedIcon from '@material-ui/icons/HomeOutlined';
import React, { useCallback } from 'react';
import { MemoryRouter } from 'react-router-dom';
import {
Sidebar,
SidebarDivider,
SidebarIntro,
SidebarItem,
SidebarSearchField,
SidebarSpace,
SidebarPage,
} from '.';
import { useLayoutContent } from '../LayoutProvider';
import { PluginWithTable } from '../Page/Page.stories';
export default {
title: 'Layout/SidebarPage',
component: SidebarPage,
decorators: [
(storyFn: () => JSX.Element) => (
<MemoryRouter initialEntries={['/']}>{storyFn()}</MemoryRouter>
),
],
};
export const SampleSidebarPage = () => {
return (
<SidebarPage>
<CustomSidebar />
<PluginWithTable />
</SidebarPage>
);
};
function CustomSidebar() {
const { focusContent } = useLayoutContent();
const handleSearch = (input: string) => {
focusContent();
// eslint-disable-next-line no-console
console.log(input);
};
return (
<Sidebar>
<SidebarSearchField onSearch={handleSearch} to="/search" />
<SidebarDivider />
<SidebarItem icon={HomeOutlinedIcon} to="#" text="Home" />
<SidebarItem icon={HomeOutlinedIcon} to="#" text="Plugins" />
<SidebarItem icon={AddCircleOutlineIcon} to="#" text="Create..." />
<SidebarDivider />
<SidebarIntro />
<SidebarSpace />
</Sidebar>
);
}
@@ -26,6 +26,7 @@ export type {
SidebarSubmenuItemProps,
SidebarSubmenuItemDropdownItem,
} from './SidebarSubmenuItem';
export type { SidebarClassKey, SidebarProps } from './Bar';
export {
SidebarPage,
@@ -36,6 +37,7 @@ export type {
SidebarPageClassKey,
SidebarPageProps,
} from './Page';
export {
SidebarDivider,
SidebarItem,
@@ -18,8 +18,8 @@ import React, { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { rootRouteRef } from '../../plugin';
import { SidebarSearchField } from '@backstage/core-components';
import { useRouteRef, IconComponent } from '@backstage/core-plugin-api';
import { SidebarSearchField, useContentRef } from '@backstage/core-components';
export type SidebarSearchProps = {
icon?: IconComponent;
@@ -27,14 +27,15 @@ export type SidebarSearchProps = {
export const SidebarSearch = (props: SidebarSearchProps) => {
const searchRoute = useRouteRef(rootRouteRef);
const contentRef = useContentRef();
const navigate = useNavigate();
const handleSearch = useCallback(
(query: string): void => {
const queryString = qs.stringify({ query }, { addQueryPrefix: true });
contentRef?.current?.focus();
navigate(`${searchRoute()}${queryString}`);
},
[navigate, searchRoute],
[navigate, searchRoute, contentRef],
);
return (