refactor: split and cleanup theme/DocPage (#7006)

This commit is contained in:
Sébastien Lorber 2022-03-25 19:58:28 +01:00 committed by GitHub
parent 2964e6f65d
commit 1b974e8b1b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 299 additions and 163 deletions

View file

@ -275,6 +275,40 @@ declare module '@theme/DocPage' {
export default function DocPage(props: Props): JSX.Element; export default function DocPage(props: Props): JSX.Element;
} }
declare module '@theme/DocPage/Layout' {
import type {ReactNode} from 'react';
export interface Props {
children: ReactNode;
}
export default function DocPageLayout(props: Props): JSX.Element;
}
declare module '@theme/DocPage/Layout/Aside' {
import type {Dispatch, SetStateAction} from 'react';
import type {PropSidebar} from '@docusaurus/plugin-content-docs';
export interface Props {
sidebar: PropSidebar;
hiddenSidebarContainer: boolean;
setHiddenSidebarContainer: Dispatch<SetStateAction<boolean>>;
}
export default function DocPageLayoutAside(props: Props): JSX.Element;
}
declare module '@theme/DocPage/Layout/Main' {
import type {ReactNode} from 'react';
export interface Props {
hiddenSidebarContainer: boolean;
children: ReactNode;
}
export default function DocPageLayoutMain(props: Props): JSX.Element;
}
// TODO until TS supports exports field... hope it's in 4.6 // TODO until TS supports exports field... hope it's in 4.6
declare module '@docusaurus/plugin-content-docs/client' { declare module '@docusaurus/plugin-content-docs/client' {
export type ActivePlugin = { export type ActivePlugin = {

View file

@ -0,0 +1,100 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {type ReactNode, useState, useCallback} from 'react';
import DocSidebar from '@theme/DocSidebar';
import IconArrow from '@theme/IconArrow';
import {translate} from '@docusaurus/Translate';
import {useLocation} from '@docusaurus/router';
import type {Props} from '@theme/DocPage/Layout/Aside';
import clsx from 'clsx';
import styles from './styles.module.css';
import {ThemeClassNames, useDocsSidebar} from '@docusaurus/theme-common';
function SidebarExpandButton({toggleSidebar}: {toggleSidebar: () => void}) {
return (
<div
className={styles.collapsedDocSidebar}
title={translate({
id: 'theme.docs.sidebar.expandButtonTitle',
message: 'Expand sidebar',
description:
'The ARIA label and title attribute for expand button of doc sidebar',
})}
aria-label={translate({
id: 'theme.docs.sidebar.expandButtonAriaLabel',
message: 'Expand sidebar',
description:
'The ARIA label and title attribute for expand button of doc sidebar',
})}
tabIndex={0}
role="button"
onKeyDown={toggleSidebar}
onClick={toggleSidebar}>
<IconArrow className={styles.expandSidebarButtonIcon} />
</div>
);
}
// Reset sidebar state when sidebar changes
// Use React key to unmount/remount the children
// See https://github.com/facebook/docusaurus/issues/3414
function ResetOnSidebarChange({children}: {children: ReactNode}) {
const sidebar = useDocsSidebar();
return (
<React.Fragment key={sidebar?.name ?? 'noSidebar'}>
{children}
</React.Fragment>
);
}
export default function DocPageLayoutAside({
sidebar,
hiddenSidebarContainer,
setHiddenSidebarContainer,
}: Props): JSX.Element {
const {pathname} = useLocation();
const [hiddenSidebar, setHiddenSidebar] = useState(false);
const toggleSidebar = useCallback(() => {
if (hiddenSidebar) {
setHiddenSidebar(false);
}
setHiddenSidebarContainer((value) => !value);
}, [setHiddenSidebarContainer, hiddenSidebar]);
return (
<aside
className={clsx(
ThemeClassNames.docs.docSidebarContainer,
styles.docSidebarContainer,
hiddenSidebarContainer && styles.docSidebarContainerHidden,
)}
onTransitionEnd={(e) => {
if (!e.currentTarget.classList.contains(styles.docSidebarContainer!)) {
return;
}
if (hiddenSidebarContainer) {
setHiddenSidebar(true);
}
}}>
<ResetOnSidebarChange>
<DocSidebar
sidebar={sidebar}
path={pathname}
onCollapse={toggleSidebar}
isHidden={hiddenSidebar}
/>
</ResetOnSidebarChange>
{hiddenSidebar && <SidebarExpandButton toggleSidebar={toggleSidebar} />}
</aside>
);
}

View file

@ -0,0 +1,37 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import {useDocsSidebar} from '@docusaurus/theme-common';
import clsx from 'clsx';
import styles from './styles.module.css';
import type {Props} from '@theme/DocPage/Layout/Main';
export default function DocPageLayoutMain({
hiddenSidebarContainer,
children,
}: Props): JSX.Element {
const sidebar = useDocsSidebar();
return (
<main
className={clsx(
styles.docMainContainer,
(hiddenSidebarContainer || !sidebar) && styles.docMainContainerEnhanced,
)}>
<div
className={clsx(
'container padding-top--md padding-bottom--lg',
styles.docItemWrapper,
hiddenSidebarContainer && styles.docItemWrapperEnhanced,
)}>
{children}
</div>
</main>
);
}

View file

@ -0,0 +1,39 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {useState} from 'react';
import Layout from '@theme/Layout';
import BackToTopButton from '@theme/BackToTopButton';
import type {Props} from '@theme/DocPage/Layout';
import DocPageLayoutAside from '@theme/DocPage/Layout/Aside';
import DocPageLayoutMain from '@theme/DocPage/Layout/Main';
import styles from './styles.module.css';
import {useDocsSidebar} from '@docusaurus/theme-common';
export default function DocPageLayout({children}: Props): JSX.Element {
const sidebar = useDocsSidebar();
const [hiddenSidebarContainer, setHiddenSidebarContainer] = useState(false);
return (
<Layout>
<BackToTopButton />
<div className={styles.docPage}>
{sidebar && (
<DocPageLayoutAside
sidebar={sidebar.items}
hiddenSidebarContainer={hiddenSidebarContainer}
setHiddenSidebarContainer={setHiddenSidebarContainer}
/>
)}
<DocPageLayoutMain hiddenSidebarContainer={hiddenSidebarContainer}>
{children}
</DocPageLayoutMain>
</div>
</Layout>
);
}

View file

@ -5,145 +5,30 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import React, {type ReactNode, useState, useCallback} from 'react'; import React from 'react';
import renderRoutes from '@docusaurus/renderRoutes'; import renderRoutes from '@docusaurus/renderRoutes';
import type {PropVersionMetadata} from '@docusaurus/plugin-content-docs'; import type {PropSidebar} from '@docusaurus/plugin-content-docs';
import Layout from '@theme/Layout';
import DocSidebar from '@theme/DocSidebar';
import NotFound from '@theme/NotFound'; import NotFound from '@theme/NotFound';
import type {DocumentRoute} from '@theme/DocItem';
import type {Props} from '@theme/DocPage'; import type {Props} from '@theme/DocPage';
import IconArrow from '@theme/IconArrow'; import DocPageLayout from '@theme/DocPage/Layout';
import BackToTopButton from '@theme/BackToTopButton';
import {matchPath} from '@docusaurus/router'; import {matchPath} from '@docusaurus/router';
import {translate} from '@docusaurus/Translate';
import clsx from 'clsx'; import clsx from 'clsx';
import styles from './styles.module.css';
import { import {
HtmlClassNameProvider, HtmlClassNameProvider,
ThemeClassNames, ThemeClassNames,
docVersionSearchTag, docVersionSearchTag,
DocsSidebarProvider, DocsSidebarProvider,
useDocsSidebar,
DocsVersionProvider, DocsVersionProvider,
} from '@docusaurus/theme-common'; } from '@docusaurus/theme-common';
import SearchMetadata from '@theme/SearchMetadata'; import SearchMetadata from '@theme/SearchMetadata';
type DocPageContentProps = { function extractDocRouteMetadata(props: Props): null | {
readonly currentDocRoute: DocumentRoute; docElement: JSX.Element;
readonly versionMetadata: PropVersionMetadata; sidebarName: string | undefined;
readonly children: ReactNode; sidebarItems: PropSidebar | undefined;
readonly sidebarName: string | undefined; } {
};
function DocPageContent({
currentDocRoute,
versionMetadata,
children,
sidebarName,
}: DocPageContentProps): JSX.Element {
const sidebar = useDocsSidebar();
const {pluginId, version} = versionMetadata;
const [hiddenSidebarContainer, setHiddenSidebarContainer] = useState(false);
const [hiddenSidebar, setHiddenSidebar] = useState(false);
const toggleSidebar = useCallback(() => {
if (hiddenSidebar) {
setHiddenSidebar(false);
}
setHiddenSidebarContainer((value) => !value);
}, [hiddenSidebar]);
return (
<>
<SearchMetadata
version={version}
tag={docVersionSearchTag(pluginId, version)}
/>
<Layout>
<div className={styles.docPage}>
<BackToTopButton />
{sidebar && (
<aside
className={clsx(
ThemeClassNames.docs.docSidebarContainer,
styles.docSidebarContainer,
hiddenSidebarContainer && styles.docSidebarContainerHidden,
)}
onTransitionEnd={(e) => {
if (
!e.currentTarget.classList.contains(
styles.docSidebarContainer!,
)
) {
return;
}
if (hiddenSidebarContainer) {
setHiddenSidebar(true);
}
}}>
<DocSidebar
key={
// Reset sidebar state on sidebar changes
// See https://github.com/facebook/docusaurus/issues/3414
sidebarName
}
sidebar={sidebar}
path={currentDocRoute.path}
onCollapse={toggleSidebar}
isHidden={hiddenSidebar}
/>
{hiddenSidebar && (
<div
className={styles.collapsedDocSidebar}
title={translate({
id: 'theme.docs.sidebar.expandButtonTitle',
message: 'Expand sidebar',
description:
'The ARIA label and title attribute for expand button of doc sidebar',
})}
aria-label={translate({
id: 'theme.docs.sidebar.expandButtonAriaLabel',
message: 'Expand sidebar',
description:
'The ARIA label and title attribute for expand button of doc sidebar',
})}
tabIndex={0}
role="button"
onKeyDown={toggleSidebar}
onClick={toggleSidebar}>
<IconArrow className={styles.expandSidebarButtonIcon} />
</div>
)}
</aside>
)}
<main
className={clsx(
styles.docMainContainer,
(hiddenSidebarContainer || !sidebar) &&
styles.docMainContainerEnhanced,
)}>
<div
className={clsx(
'container padding-top--md padding-bottom--lg',
styles.docItemWrapper,
hiddenSidebarContainer && styles.docItemWrapperEnhanced,
)}>
{children}
</div>
</main>
</div>
</Layout>
</>
);
}
export default function DocPage(props: Props): JSX.Element {
const { const {
route: {routes: docRoutes}, route: {routes: docRoutes},
versionMetadata, versionMetadata,
@ -153,33 +38,55 @@ export default function DocPage(props: Props): JSX.Element {
matchPath(location.pathname, docRoute), matchPath(location.pathname, docRoute),
); );
if (!currentDocRoute) { if (!currentDocRoute) {
return <NotFound />; return null;
} }
// For now, the sidebarName is added as route config: not ideal! // For now, the sidebarName is added as route config: not ideal!
const sidebarName = currentDocRoute.sidebar; const sidebarName = currentDocRoute.sidebar;
const sidebar = sidebarName const sidebarItems = sidebarName
? versionMetadata.docsSidebars[sidebarName] ? versionMetadata.docsSidebars[sidebarName]
: null; : undefined;
const docElement = renderRoutes(props.route.routes, {
versionMetadata,
});
return {
docElement,
sidebarName,
sidebarItems,
};
}
export default function DocPage(props: Props): JSX.Element {
const {versionMetadata} = props;
const currentDocRouteMetadata = extractDocRouteMetadata(props);
if (!currentDocRouteMetadata) {
return <NotFound />;
}
const {docElement, sidebarName, sidebarItems} = currentDocRouteMetadata;
return ( return (
<HtmlClassNameProvider <>
className={clsx( <SearchMetadata
ThemeClassNames.wrapper.docsPages, version={versionMetadata.version}
ThemeClassNames.page.docsDocPage, tag={docVersionSearchTag(
versionMetadata.className, versionMetadata.pluginId,
)}> versionMetadata.version,
<DocsVersionProvider version={versionMetadata}> )}
<DocsSidebarProvider sidebar={sidebar ?? null}> />
<DocPageContent <HtmlClassNameProvider
currentDocRoute={currentDocRoute} className={clsx(
versionMetadata={versionMetadata} ThemeClassNames.wrapper.docsPages,
sidebarName={sidebarName}> ThemeClassNames.page.docsDocPage,
{renderRoutes(docRoutes, {versionMetadata})} props.versionMetadata.className,
</DocPageContent> )}>
</DocsSidebarProvider> <DocsVersionProvider version={versionMetadata}>
</DocsVersionProvider> <DocsSidebarProvider name={sidebarName} items={sidebarItems}>
</HtmlClassNameProvider> <DocPageLayout>{docElement}</DocPageLayout>
</DocsSidebarProvider>
</DocsVersionProvider>
</HtmlClassNameProvider>
</>
); );
} }

View file

@ -13,19 +13,24 @@ import type {PropSidebar} from '@docusaurus/plugin-content-docs';
describe('useDocsSidebar', () => { describe('useDocsSidebar', () => {
it('throws if context provider is missing', () => { it('throws if context provider is missing', () => {
expect( expect(
() => renderHook(() => useDocsSidebar()).result.current, () => renderHook(() => useDocsSidebar()).result.current?.items,
).toThrowErrorMatchingInlineSnapshot( ).toThrowErrorMatchingInlineSnapshot(
`"Hook useDocsSidebar is called outside the <DocsSidebarProvider>. "`, `"Hook useDocsSidebar is called outside the <DocsSidebarProvider>. "`,
); );
}); });
it('reads value from context provider', () => { it('reads value from context provider', () => {
const sidebar: PropSidebar = []; const name = 'mySidebarName';
const items: PropSidebar = [];
const {result} = renderHook(() => useDocsSidebar(), { const {result} = renderHook(() => useDocsSidebar(), {
wrapper: ({children}) => ( wrapper: ({children}) => (
<DocsSidebarProvider sidebar={sidebar}>{children}</DocsSidebarProvider> <DocsSidebarProvider name={name} items={items}>
{children}
</DocsSidebarProvider>
), ),
}); });
expect(result.current).toBe(sidebar); expect(result.current).toBeDefined();
expect(result.current!.name).toBe(name);
expect(result.current!.items).toBe(items);
}); });
}); });

View file

@ -5,7 +5,7 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import React, {type ReactNode, useContext} from 'react'; import React, {useMemo, useContext, type ReactNode} from 'react';
import type {PropSidebar} from '@docusaurus/plugin-content-docs'; import type {PropSidebar} from '@docusaurus/plugin-content-docs';
import {ReactContextError} from '../utils/reactUtils'; import {ReactContextError} from '../utils/reactUtils';
@ -13,30 +13,44 @@ import {ReactContextError} from '../utils/reactUtils';
// Inspired by https://github.com/jamiebuilds/unstated-next/blob/master/src/unstated-next.tsx // Inspired by https://github.com/jamiebuilds/unstated-next/blob/master/src/unstated-next.tsx
const EmptyContext: unique symbol = Symbol('EmptyContext'); const EmptyContext: unique symbol = Symbol('EmptyContext');
const Context = React.createContext<PropSidebar | null | typeof EmptyContext>( type SidebarContextValue = {name: string; items: PropSidebar};
EmptyContext,
); const Context = React.createContext<
SidebarContextValue | null | typeof EmptyContext
>(EmptyContext);
/** /**
* Provide the current sidebar to your children. * Provide the current sidebar to your children.
*/ */
export function DocsSidebarProvider({ export function DocsSidebarProvider({
children, children,
sidebar, name,
items,
}: { }: {
children: ReactNode; children: ReactNode;
sidebar: PropSidebar | null; name: string | undefined;
items: PropSidebar | undefined;
}): JSX.Element { }): JSX.Element {
return <Context.Provider value={sidebar}>{children}</Context.Provider>; const stableValue: SidebarContextValue | null = useMemo(
() =>
name && items
? {
name,
items,
}
: null,
[name, items],
);
return <Context.Provider value={stableValue}>{children}</Context.Provider>;
} }
/** /**
* Gets the sidebar that's currently displayed, or `null` if there isn't one * Gets the sidebar data that's currently displayed, or `null` if there isn't one
*/ */
export function useDocsSidebar(): PropSidebar | null { export function useDocsSidebar(): SidebarContextValue | null {
const sidebar = useContext(Context); const value = useContext(Context);
if (sidebar === EmptyContext) { if (value === EmptyContext) {
throw new ReactContextError('DocsSidebarProvider'); throw new ReactContextError('DocsSidebarProvider');
} }
return sidebar; return value;
} }

View file

@ -307,7 +307,7 @@ describe('useSidebarBreadcrumbs', () => {
}, },
}, },
}}> }}>
<DocsSidebarProvider sidebar={sidebar}> <DocsSidebarProvider name="sidebarName" items={sidebar}>
{children} {children}
</DocsSidebarProvider> </DocsSidebarProvider>
</Context.Provider> </Context.Provider>
@ -430,7 +430,7 @@ describe('useCurrentSidebarCategory', () => {
(sidebar?: PropSidebar) => (location: string) => (sidebar?: PropSidebar) => (location: string) =>
renderHook(() => useCurrentSidebarCategory(), { renderHook(() => useCurrentSidebarCategory(), {
wrapper: ({children}) => ( wrapper: ({children}) => (
<DocsSidebarProvider sidebar={sidebar}> <DocsSidebarProvider name="sidebarName" items={sidebar}>
<StaticRouter location={location}>{children}</StaticRouter> <StaticRouter location={location}>{children}</StaticRouter>
</DocsSidebarProvider> </DocsSidebarProvider>
), ),

View file

@ -105,7 +105,7 @@ export function useCurrentSidebarCategory(): PropSidebarItemCategory {
if (!sidebar) { if (!sidebar) {
throw new Error('Unexpected: cant find current sidebar in context'); throw new Error('Unexpected: cant find current sidebar in context');
} }
const category = findSidebarCategory(sidebar, (item) => const category = findSidebarCategory(sidebar.items, (item) =>
isSamePath(item.href, pathname), isSamePath(item.href, pathname),
); );
if (!category) { if (!category) {
@ -174,7 +174,7 @@ export function useSidebarBreadcrumbs(): PropSidebarBreadcrumbsItem[] | null {
return false; return false;
} }
extract(sidebar); extract(sidebar.items);
return breadcrumbs.reverse(); return breadcrumbs.reverse();
} }