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;
}
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
declare module '@docusaurus/plugin-content-docs/client' {
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.
*/
import React, {type ReactNode, useState, useCallback} from 'react';
import React from 'react';
import renderRoutes from '@docusaurus/renderRoutes';
import type {PropVersionMetadata} from '@docusaurus/plugin-content-docs';
import Layout from '@theme/Layout';
import DocSidebar from '@theme/DocSidebar';
import type {PropSidebar} from '@docusaurus/plugin-content-docs';
import NotFound from '@theme/NotFound';
import type {DocumentRoute} from '@theme/DocItem';
import type {Props} from '@theme/DocPage';
import IconArrow from '@theme/IconArrow';
import BackToTopButton from '@theme/BackToTopButton';
import DocPageLayout from '@theme/DocPage/Layout';
import {matchPath} from '@docusaurus/router';
import {translate} from '@docusaurus/Translate';
import clsx from 'clsx';
import styles from './styles.module.css';
import {
HtmlClassNameProvider,
ThemeClassNames,
docVersionSearchTag,
DocsSidebarProvider,
useDocsSidebar,
DocsVersionProvider,
} from '@docusaurus/theme-common';
import SearchMetadata from '@theme/SearchMetadata';
type DocPageContentProps = {
readonly currentDocRoute: DocumentRoute;
readonly versionMetadata: PropVersionMetadata;
readonly children: ReactNode;
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 {
function extractDocRouteMetadata(props: Props): null | {
docElement: JSX.Element;
sidebarName: string | undefined;
sidebarItems: PropSidebar | undefined;
} {
const {
route: {routes: docRoutes},
versionMetadata,
@ -153,33 +38,55 @@ export default function DocPage(props: Props): JSX.Element {
matchPath(location.pathname, docRoute),
);
if (!currentDocRoute) {
return <NotFound />;
return null;
}
// For now, the sidebarName is added as route config: not ideal!
const sidebarName = currentDocRoute.sidebar;
const sidebar = sidebarName
const sidebarItems = 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 (
<HtmlClassNameProvider
className={clsx(
ThemeClassNames.wrapper.docsPages,
ThemeClassNames.page.docsDocPage,
versionMetadata.className,
)}>
<DocsVersionProvider version={versionMetadata}>
<DocsSidebarProvider sidebar={sidebar ?? null}>
<DocPageContent
currentDocRoute={currentDocRoute}
versionMetadata={versionMetadata}
sidebarName={sidebarName}>
{renderRoutes(docRoutes, {versionMetadata})}
</DocPageContent>
</DocsSidebarProvider>
</DocsVersionProvider>
</HtmlClassNameProvider>
<>
<SearchMetadata
version={versionMetadata.version}
tag={docVersionSearchTag(
versionMetadata.pluginId,
versionMetadata.version,
)}
/>
<HtmlClassNameProvider
className={clsx(
ThemeClassNames.wrapper.docsPages,
ThemeClassNames.page.docsDocPage,
props.versionMetadata.className,
)}>
<DocsVersionProvider version={versionMetadata}>
<DocsSidebarProvider name={sidebarName} items={sidebarItems}>
<DocPageLayout>{docElement}</DocPageLayout>
</DocsSidebarProvider>
</DocsVersionProvider>
</HtmlClassNameProvider>
</>
);
}

View file

@ -13,19 +13,24 @@ import type {PropSidebar} from '@docusaurus/plugin-content-docs';
describe('useDocsSidebar', () => {
it('throws if context provider is missing', () => {
expect(
() => renderHook(() => useDocsSidebar()).result.current,
() => renderHook(() => useDocsSidebar()).result.current?.items,
).toThrowErrorMatchingInlineSnapshot(
`"Hook useDocsSidebar is called outside the <DocsSidebarProvider>. "`,
);
});
it('reads value from context provider', () => {
const sidebar: PropSidebar = [];
const name = 'mySidebarName';
const items: PropSidebar = [];
const {result} = renderHook(() => useDocsSidebar(), {
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.
*/
import React, {type ReactNode, useContext} from 'react';
import React, {useMemo, useContext, type ReactNode} from 'react';
import type {PropSidebar} from '@docusaurus/plugin-content-docs';
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
const EmptyContext: unique symbol = Symbol('EmptyContext');
const Context = React.createContext<PropSidebar | null | typeof EmptyContext>(
EmptyContext,
);
type SidebarContextValue = {name: string; items: PropSidebar};
const Context = React.createContext<
SidebarContextValue | null | typeof EmptyContext
>(EmptyContext);
/**
* Provide the current sidebar to your children.
*/
export function DocsSidebarProvider({
children,
sidebar,
name,
items,
}: {
children: ReactNode;
sidebar: PropSidebar | null;
name: string | undefined;
items: PropSidebar | undefined;
}): 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 {
const sidebar = useContext(Context);
if (sidebar === EmptyContext) {
export function useDocsSidebar(): SidebarContextValue | null {
const value = useContext(Context);
if (value === EmptyContext) {
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}
</DocsSidebarProvider>
</Context.Provider>
@ -430,7 +430,7 @@ describe('useCurrentSidebarCategory', () => {
(sidebar?: PropSidebar) => (location: string) =>
renderHook(() => useCurrentSidebarCategory(), {
wrapper: ({children}) => (
<DocsSidebarProvider sidebar={sidebar}>
<DocsSidebarProvider name="sidebarName" items={sidebar}>
<StaticRouter location={location}>{children}</StaticRouter>
</DocsSidebarProvider>
),

View file

@ -105,7 +105,7 @@ export function useCurrentSidebarCategory(): PropSidebarItemCategory {
if (!sidebar) {
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),
);
if (!category) {
@ -174,7 +174,7 @@ export function useSidebarBreadcrumbs(): PropSidebarBreadcrumbsItem[] | null {
return false;
}
extract(sidebar);
extract(sidebar.items);
return breadcrumbs.reverse();
}