Merge pull request #8455 from Sarabadu/master
[RFC] LayoutProvider and focusContent
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user