Merge pull request #8455 from Sarabadu/master

[RFC] LayoutProvider and focusContent
This commit is contained in:
Fredrik Adelöw
2022-01-19 09:32:16 +01:00
committed by GitHub
9 changed files with 188 additions and 26 deletions
+6
View File
@@ -2403,6 +2403,12 @@ export function TrendLine(
},
): JSX.Element | null;
// @public
export function useContent(): {
focusContent: () => void;
contentRef: React_2.MutableRefObject<HTMLElement | 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 { useContent } from '../Sidebar';
/** @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 } = useContent();
const classes = useStyles();
return (
<article
ref={contentRef}
tabIndex={-1}
{...restProps}
className={classNames(classes.root, className, {
[classes.stretch]: stretch,
@@ -17,10 +17,13 @@
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 Button from '@material-ui/core/Button';
import { sidebarConfig, SidebarContext } from './config';
import { BackstageTheme } from '@backstage/theme';
import { SidebarPinStateContext } from './Page';
import { SidebarPinStateContext, useContent } from './Page';
import { MobileSidebar } from './MobileSidebar';
/** @public */
@@ -60,6 +63,15 @@ const useStyles = makeStyles<BackstageTheme>(
duration: theme.transitions.duration.shorter,
}),
},
visuallyHidden: {
top: 0,
position: 'absolute',
zIndex: 1000,
transform: 'translateY(-200%)',
'&:focus': {
transform: 'translateY(5px)',
},
},
}),
{ name: 'BackstageSidebar' },
);
@@ -158,25 +170,36 @@ 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={{}}>
<A11ySkipSidebar />
<SidebarContext.Provider
value={{
isOpen,
setOpen,
}}
>
{children}
</div>
</SidebarContext.Provider>
<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 +224,31 @@ export const Sidebar = (props: SidebarProps) => {
</DesktopSidebar>
);
};
function A11ySkipSidebar() {
const { focusContent, contentRef } = useContent();
const classes = useStyles();
if (!contentRef?.current) {
return null;
}
return (
<Button
onClick={focusContent}
variant="contained"
className={classnames(classes.visuallyHidden)}
>
Skip to content
</Button>
);
}
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,16 @@
*/
import { makeStyles } from '@material-ui/core/styles';
import React, { createContext, useEffect, useState } from 'react';
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { sidebarConfig } from './config';
import { BackstageTheme } from '@backstage/theme';
import { LocalStorage } from './localStorage';
@@ -28,6 +37,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 +48,13 @@ const useStyles = makeStyles<BackstageTheme, { isPinned: boolean }>(
paddingBottom: sidebarConfig.mobileSidebarHeight,
},
},
content: {
zIndex: 0,
isolation: 'isolate',
'&:focus': {
outline: 0,
},
},
}),
{ name: 'BackstageSidebarPage' },
);
@@ -75,11 +92,33 @@ export const SidebarPinStateContext = createContext<SidebarPinStateContextType>(
},
);
type PageContextType = {
content: {
contentRef?: React.MutableRefObject<HTMLElement | null>;
};
};
const PageContext = createContext<PageContextType>({
content: {
contentRef: undefined,
},
});
export function SidebarPage(props: SidebarPageProps) {
const [isPinned, setIsPinned] = useState(() =>
LocalStorage.getSidebarPinState(),
);
const contentRef = useRef(null);
const pageContext = useMemo(
() => ({
content: {
contentRef,
},
}),
[contentRef],
);
useEffect(() => {
LocalStorage.setSidebarPinState(isPinned);
}, [isPinned]);
@@ -92,6 +131,7 @@ export function SidebarPage(props: SidebarPageProps) {
const toggleSidebarPinState = () => setIsPinned(!isPinned);
const classes = useStyles({ isPinned });
return (
<SidebarPinStateContext.Provider
value={{
@@ -100,7 +140,43 @@ export function SidebarPage(props: SidebarPageProps) {
isMobile,
}}
>
<div className={classes.root}>{props.children}</div>
<PageContext.Provider value={pageContext}>
<div className={classes.root}>{props.children}</div>
</PageContext.Provider>
</SidebarPinStateContext.Provider>
);
}
/**
* This hook provides a react ref to the main content.
* Allows to set an element as the main content and focus on that component.
*
* *Note: If `contentRef` is not set `focusContent` is noop. `Content` component sets this ref automaticaly*
*
* @public
* @example
* Focus current content
* ```tsx
* const { focusContent} = useContent();
* ...
* <Button onClick={focusContent}>
* focus main content
* </Button>
* ```
* @example
* Set the reference to an Html element
* ```
* const { contentRef } = useContent();
* ...
* <article ref={contentRef} tabIndex={-1}>Main Content</article>
* ```
*/
export function useContent() {
const { content } = useContext(PageContext);
const focusContent = useCallback(() => {
content?.contentRef?.current?.focus();
}, [content]);
return { focusContent, contentRef: content?.contentRef };
}
@@ -30,6 +30,7 @@ export type { SidebarClassKey, SidebarProps } from './Bar';
export {
SidebarPage,
SidebarPinStateContext as SidebarPinStateContext,
useContent,
} from './Page';
export type {
SidebarPinStateContextType as SidebarPinStateContextType,