diff --git a/.changeset/eight-insects-tan.md b/.changeset/eight-insects-tan.md new file mode 100644 index 0000000000..13a53b227a --- /dev/null +++ b/.changeset/eight-insects-tan.md @@ -0,0 +1,6 @@ +--- +'@backstage/core-components': patch +--- + +- Add `useContent` hook to have a reference to the current main content element +- Sets the main content reference on `Content` component diff --git a/.changeset/eleven-sloths-shake.md b/.changeset/eleven-sloths-shake.md new file mode 100644 index 0000000000..64602b561d --- /dev/null +++ b/.changeset/eleven-sloths-shake.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search': patch +--- + +Removes the focus from the sidebar and focus the main content after select one search result or navigate to the search result list diff --git a/packages/core-components/api-report.md b/packages/core-components/api-report.md index 923e9b2e3e..d8aef17007 100644 --- a/packages/core-components/api-report.md +++ b/packages/core-components/api-report.md @@ -2403,6 +2403,12 @@ export function TrendLine( }, ): JSX.Element | null; +// @public +export function useContent(): { + focusContent: () => void; + contentRef: React_2.MutableRefObject | 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) // diff --git a/packages/core-components/src/layout/Content/Content.tsx b/packages/core-components/src/layout/Content/Content.tsx index 1d91556663..a26f74710d 100644 --- a/packages/core-components/src/layout/Content/Content.tsx +++ b/packages/core-components/src/layout/Content/Content.tsx @@ -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) { const { className, stretch, noPadding, children, ...restProps } = props; + + const { contentRef } = useContent(); + const classes = useStyles(); return (
( 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 ( - -
+ + - {children} -
-
+
{} : handleOpen} + onFocus={ + disableExpandOnHover ? () => {} : ignoreChildEvent(handleOpen) + } + onMouseLeave={disableExpandOnHover ? () => {} : handleClose} + onBlur={ + disableExpandOnHover ? () => {} : ignoreChildEvent(handleClose) + } + > +
+ {children} +
+
+ + ); }; @@ -201,3 +224,31 @@ export const Sidebar = (props: SidebarProps) => { ); }; + +function A11ySkipSidebar() { + const { focusContent, contentRef } = useContent(); + const classes = useStyles(); + + if (!contentRef?.current) { + return null; + } + return ( + + ); +} + +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); + } + }; +} diff --git a/packages/core-components/src/layout/Sidebar/Page.tsx b/packages/core-components/src/layout/Sidebar/Page.tsx index f23a6fa3b6..3b0efb62c1 100644 --- a/packages/core-components/src/layout/Sidebar/Page.tsx +++ b/packages/core-components/src/layout/Sidebar/Page.tsx @@ -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( 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( paddingBottom: sidebarConfig.mobileSidebarHeight, }, }, + content: { + zIndex: 0, + isolation: 'isolate', + '&:focus': { + outline: 0, + }, + }, }), { name: 'BackstageSidebarPage' }, ); @@ -75,11 +92,33 @@ export const SidebarPinStateContext = createContext( }, ); +type PageContextType = { + content: { + contentRef?: React.MutableRefObject; + }; +}; + +const PageContext = createContext({ + 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 ( -
{props.children}
+ +
{props.children}
+
); } + +/** + * 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(); + * ... + * + * ``` + * @example + * Set the reference to an Html element + * ``` + * const { contentRef } = useContent(); + * ... + *
Main Content
+ * ``` + */ +export function useContent() { + const { content } = useContext(PageContext); + + const focusContent = useCallback(() => { + content?.contentRef?.current?.focus(); + }, [content]); + + return { focusContent, contentRef: content?.contentRef }; +} diff --git a/packages/core-components/src/layout/Sidebar/index.ts b/packages/core-components/src/layout/Sidebar/index.ts index d175b364da..96fedaa75a 100644 --- a/packages/core-components/src/layout/Sidebar/index.ts +++ b/packages/core-components/src/layout/Sidebar/index.ts @@ -30,6 +30,7 @@ export type { SidebarClassKey, SidebarProps } from './Bar'; export { SidebarPage, SidebarPinStateContext as SidebarPinStateContext, + useContent, } from './Page'; export type { SidebarPinStateContextType as SidebarPinStateContextType, diff --git a/plugins/search/src/components/SearchModal/SearchModal.tsx b/plugins/search/src/components/SearchModal/SearchModal.tsx index c7ca33005d..1f0a879f96 100644 --- a/plugins/search/src/components/SearchModal/SearchModal.tsx +++ b/plugins/search/src/components/SearchModal/SearchModal.tsx @@ -24,6 +24,7 @@ import { Grid, List, Paper, + useTheme, } from '@material-ui/core'; import LaunchIcon from '@material-ui/icons/Launch'; import { makeStyles } from '@material-ui/core/styles'; @@ -33,7 +34,7 @@ import { SearchResult } from '../SearchResult'; import { SearchContextProvider, useSearch } from '../SearchContext'; import { SearchResultPager } from '../SearchResultPager'; import { useRouteRef } from '@backstage/core-plugin-api'; -import { Link } from '@backstage/core-components'; +import { Link, useContent } from '@backstage/core-components'; import { rootRouteRef } from '../../plugin'; export interface SearchModalProps { @@ -61,9 +62,12 @@ export const Modal = ({ open = true, toggleModal }: SearchModalProps) => { const classes = useStyles(); const { term } = useSearch(); + const { focusContent } = useContent(); + const { transitions } = useTheme(); const handleResultClick = () => { toggleModal(); + setTimeout(focusContent, transitions.duration.leavingScreen); }; const handleKeyPress = () => { @@ -94,7 +98,13 @@ export const Modal = ({ open = true, toggleModal }: SearchModalProps) => { alignItems="center" > - + { + toggleModal(); + setTimeout(focusContent, transitions.duration.leavingScreen); + }} + to={`${getSearchLink()}?query=${term}`} + > View Full Results diff --git a/plugins/search/src/components/SidebarSearch/SidebarSearch.tsx b/plugins/search/src/components/SidebarSearch/SidebarSearch.tsx index 9f6be2333f..25d41fa10f 100644 --- a/plugins/search/src/components/SidebarSearch/SidebarSearch.tsx +++ b/plugins/search/src/components/SidebarSearch/SidebarSearch.tsx @@ -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, useContent } 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 { focusContent } = useContent(); const navigate = useNavigate(); const handleSearch = useCallback( (query: string): void => { const queryString = qs.stringify({ query }, { addQueryPrefix: true }); - + focusContent(); navigate(`${searchRoute()}${queryString}`); }, - [navigate, searchRoute], + [focusContent, navigate, searchRoute], ); return (