feat(theme-classic): show blog sidebar on mobile (#7012)

* feat(theme-classic): show blog sidebar on mobile

* fix

* oops

* docs

* add a little margin

* Update display.tsx

* Update content.tsx

* reformat
This commit is contained in:
Joshua Chen 2022-03-30 09:15:54 +08:00 committed by GitHub
parent 1f77fc93bb
commit 2e79597f83
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 260 additions and 188 deletions

View file

@ -8,17 +8,21 @@
import React from 'react';
import clsx from 'clsx';
import Link from '@docusaurus/Link';
import {translate} from '@docusaurus/Translate';
import {
NavbarSecondaryMenuFiller,
useWindowSize,
} from '@docusaurus/theme-common';
import type {Props} from '@theme/BlogSidebar';
import styles from './styles.module.css';
import {translate} from '@docusaurus/Translate';
export default function BlogSidebar({sidebar}: Props): JSX.Element | null {
if (sidebar.items.length === 0) {
return null;
}
function BlogSidebarContent({
sidebar,
className,
}: Props & {className?: string}): JSX.Element {
return (
<nav
className={clsx(styles.sidebar, 'thin-scrollbar')}
className={clsx(styles.sidebar, 'thin-scrollbar', className)}
aria-label={translate({
id: 'theme.blog.sidebar.navAriaLabel',
message: 'Blog recent posts navigation',
@ -43,3 +47,20 @@ export default function BlogSidebar({sidebar}: Props): JSX.Element | null {
</nav>
);
}
export default function BlogSidebar(props: Props): JSX.Element | null {
const windowSize = useWindowSize();
if (props.sidebar.items.length === 0) {
return null;
}
// Mobile sidebar doesn't need to be server-rendered
if (windowSize === 'mobile') {
return (
<NavbarSecondaryMenuFiller
component={BlogSidebarContent}
props={{...props, className: 'margin-left--md'}}
/>
);
}
return <BlogSidebarContent {...props} className={styles.sidebarDesktop} />;
}

View file

@ -40,7 +40,11 @@
}
@media (max-width: 996px) {
.sidebar {
.sidebarDesktop {
display: none;
}
.sidebar {
top: 0;
}
}

View file

@ -12,9 +12,9 @@ import React, {
useMemo,
type ReactNode,
} from 'react';
import {useNavbarSecondaryMenuContent} from './navbarSecondaryMenu/content';
import {useWindowSize} from '../hooks/useWindowSize';
import {useHistoryPopHandler} from '../utils/historyUtils';
import {useActivePlugin} from '@docusaurus/plugin-content-docs/client';
import {useThemeConfig} from '../utils/useThemeConfig';
import {ReactContextError} from '../utils/reactUtils';
@ -40,9 +40,9 @@ type ContextValue = {
const Context = React.createContext<ContextValue | undefined>(undefined);
function useIsNavbarMobileSidebarDisabled() {
const activeDocPlugin = useActivePlugin();
const secondaryMenuContent = useNavbarSecondaryMenuContent();
const {items} = useThemeConfig().navbar;
return items.length === 0 && !activeDocPlugin;
return items.length === 0 && !secondaryMenuContent.component;
}
function useContextValue(): ContextValue {

View file

@ -1,170 +0,0 @@
/**
* 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,
useContext,
useEffect,
useMemo,
useCallback,
type ReactNode,
type ComponentType,
} from 'react';
import {ReactContextError, usePrevious} from '../utils/reactUtils';
import {useNavbarMobileSidebar} from './navbarMobileSidebar';
export type NavbarSecondaryMenuComponent<Props> = ComponentType<Props>;
type State = {
shown: boolean;
content:
| {
component: NavbarSecondaryMenuComponent<object>;
props: object;
}
| {component: null; props: null};
};
const InitialState: State = {
shown: false,
content: {component: null, props: null},
};
type ContextValue = [
state: State,
setState: React.Dispatch<React.SetStateAction<State>>,
];
const Context = React.createContext<ContextValue | null>(null);
function useContextValue(): ContextValue {
const mobileSidebar = useNavbarMobileSidebar();
const [state, setState] = useState<State>(InitialState);
const setShown = (shown: boolean) => setState((s) => ({...s, shown}));
const hasContent = state.content?.component !== null;
const previousHasContent = usePrevious(state.content?.component !== null);
// When content is become available for the first time (set in useEffect)
// we set this content to be shown!
useEffect(() => {
const contentBecameAvailable = hasContent && !previousHasContent;
if (contentBecameAvailable) {
setShown(true);
}
}, [hasContent, previousHasContent]);
// On sidebar close, secondary menu is set to be shown on next re-opening
// (if any secondary menu content available)
useEffect(() => {
if (!hasContent) {
setShown(false);
return;
}
if (!mobileSidebar.shown) {
setShown(true);
}
}, [mobileSidebar.shown, hasContent]);
return [state, setState];
}
export function NavbarSecondaryMenuProvider({
children,
}: {
children: ReactNode;
}): JSX.Element {
const value = useContextValue();
return <Context.Provider value={value}>{children}</Context.Provider>;
}
function useNavbarSecondaryMenuContext(): ContextValue {
const value = useContext(Context);
if (value === null) {
throw new ReactContextError('MobileSecondaryMenuProvider');
}
return value;
}
function useShallowMemoizedObject<O>(obj: O) {
return useMemo(
() => obj,
// Is this safe?
// eslint-disable-next-line react-hooks/exhaustive-deps
[...Object.keys(obj), ...Object.values(obj)],
);
}
/**
* This component renders nothing by itself, but it fills the placeholder in the
* generic secondary menu layout. This reduces coupling between the main layout
* and the specific page.
*
* This kind of feature is often called portal/teleport/gateway/outlet...
* Various unmaintained React libs exist. Most up-to-date one:
* https://github.com/gregberge/react-teleporter
* Not sure any of those is safe regarding concurrent mode.
*/
export function NavbarSecondaryMenuFiller<P extends object>({
component,
props,
}: {
component: NavbarSecondaryMenuComponent<P>;
props: P;
}): JSX.Element | null {
const [, setState] = useNavbarSecondaryMenuContext();
// To avoid useless context re-renders, props are memoized shallowly
const memoizedProps = useShallowMemoizedObject(props);
useEffect(() => {
// @ts-expect-error: context is not 100% type-safe but it's ok
setState((s) => ({...s, content: {component, props: memoizedProps}}));
}, [setState, component, memoizedProps]);
useEffect(
() => () => setState((s) => ({...s, component: null, props: null})),
[setState],
);
return null;
}
function renderElement(state: State): JSX.Element | undefined {
if (state.content?.component) {
const Comp = state.content.component;
return <Comp {...state.content.props} />;
}
return undefined;
}
/** Wires the logic for rendering the mobile navbar secondary menu. */
export function useNavbarSecondaryMenu(): {
/** Whether secondary menu is displayed. */
shown: boolean;
/**
* Hide the secondary menu; fired either when hiding the entire sidebar, or
* when going back to the primary menu.
*/
hide: () => void;
/** The content returned from the current secondary menu filler. */
content: JSX.Element | undefined;
} {
const [state, setState] = useNavbarSecondaryMenuContext();
const hide = useCallback(
() => setState((s) => ({...s, shown: false})),
[setState],
);
return useMemo(
() => ({shown: state.shown, hide, content: renderElement(state)}),
[hide, state],
);
}

View file

@ -0,0 +1,110 @@
/**
* 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,
useContext,
useEffect,
useMemo,
type ReactNode,
type ComponentType,
} from 'react';
import {ReactContextError} from '../../utils/reactUtils';
// This context represents a "global layout store". A component (usually a
// layout component) can request filling this store through
// `NavbarSecondaryMenuFiller`. It doesn't actually control rendering by itself,
// and this context should be considered internal implementation. The user-
// facing value comes from `display.tsx`, which takes the `component` and
// `props` stored here and renders the actual element.
export type NavbarSecondaryMenuComponent<Props> = ComponentType<Props>;
/** @internal */
export type Content =
| {
component: NavbarSecondaryMenuComponent<object>;
props: object;
}
| {component: null; props: null};
type ContextValue = [
content: Content,
setContent: React.Dispatch<React.SetStateAction<Content>>,
];
const Context = React.createContext<ContextValue | null>(null);
/** @internal */
export function NavbarSecondaryMenuContentProvider({
children,
}: {
children: ReactNode;
}): JSX.Element {
const value = useState({component: null, props: null});
return (
// @ts-expect-error: this context is hard to type
<Context.Provider value={value}>{children}</Context.Provider>
);
}
/** @internal */
export function useNavbarSecondaryMenuContent(): Content {
const value = useContext(Context);
if (!value) {
throw new ReactContextError('NavbarSecondaryMenuContentProvider');
}
return value[0];
}
function useShallowMemoizedObject<O>(obj: O) {
return useMemo(
() => obj,
// Is this safe?
// eslint-disable-next-line react-hooks/exhaustive-deps
[...Object.keys(obj), ...Object.values(obj)],
);
}
/**
* This component renders nothing by itself, but it fills the placeholder in the
* generic secondary menu layout. This reduces coupling between the main layout
* and the specific page.
*
* This kind of feature is often called portal/teleport/gateway/outlet...
* Various unmaintained React libs exist. Most up-to-date one:
* https://github.com/gregberge/react-teleporter
* Not sure any of those is safe regarding concurrent mode.
*/
export function NavbarSecondaryMenuFiller<P extends object>({
component,
props,
}: {
component: NavbarSecondaryMenuComponent<P>;
props: P;
}): JSX.Element | null {
const context = useContext(Context);
if (!context) {
throw new ReactContextError('NavbarSecondaryMenuContentProvider');
}
const [, setContent] = context;
// To avoid useless context re-renders, props are memoized shallowly
const memoizedProps = useShallowMemoizedObject(props);
useEffect(() => {
// @ts-expect-error: this context is hard to type
setContent({component, props: memoizedProps});
}, [setContent, component, memoizedProps]);
useEffect(
() => () => setContent({component: null, props: null}),
[setContent],
);
return null;
}

View file

@ -0,0 +1,102 @@
/**
* 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,
useContext,
useEffect,
useMemo,
useCallback,
type ReactNode,
} from 'react';
import {ReactContextError, usePrevious} from '../../utils/reactUtils';
import {useNavbarMobileSidebar} from '../navbarMobileSidebar';
import {useNavbarSecondaryMenuContent, type Content} from './content';
type ContextValue = [
shown: boolean,
setShown: React.Dispatch<React.SetStateAction<boolean>>,
];
const Context = React.createContext<ContextValue | null>(null);
function useContextValue(): ContextValue {
const mobileSidebar = useNavbarMobileSidebar();
const content = useNavbarSecondaryMenuContent();
const [shown, setShown] = useState(false);
const hasContent = content.component !== null;
const previousHasContent = usePrevious(hasContent);
// When content is become available for the first time (set in useEffect)
// we set this content to be shown!
useEffect(() => {
const contentBecameAvailable = hasContent && !previousHasContent;
if (contentBecameAvailable) {
setShown(true);
}
}, [hasContent, previousHasContent]);
// On sidebar close, secondary menu is set to be shown on next re-opening
// (if any secondary menu content available)
useEffect(() => {
if (!hasContent) {
setShown(false);
return;
}
if (!mobileSidebar.shown) {
setShown(true);
}
}, [mobileSidebar.shown, hasContent]);
return useMemo(() => [shown, setShown], [shown]);
}
/** @internal */
export function NavbarSecondaryMenuDisplayProvider({
children,
}: {
children: ReactNode;
}): JSX.Element {
const value = useContextValue();
return <Context.Provider value={value}>{children}</Context.Provider>;
}
function renderElement(content: Content): JSX.Element | undefined {
if (content.component) {
const Comp = content.component;
return <Comp {...content.props} />;
}
return undefined;
}
/** Wires the logic for rendering the mobile navbar secondary menu. */
export function useNavbarSecondaryMenu(): {
/** Whether secondary menu is displayed. */
shown: boolean;
/**
* Hide the secondary menu; fired either when hiding the entire sidebar, or
* when going back to the primary menu.
*/
hide: () => void;
/** The content returned from the current secondary menu filler. */
content: JSX.Element | undefined;
} {
const value = useContext(Context);
if (!value) {
throw new ReactContextError('NavbarSecondaryMenuDisplayProvider');
}
const [shown, setShown] = value;
const hide = useCallback(() => setShown(false), [setShown]);
const content = useNavbarSecondaryMenuContent();
return useMemo(
() => ({shown, hide, content: renderElement(content)}),
[hide, content, shown],
);
}

View file

@ -141,10 +141,10 @@ export {
export {useNavbarMobileSidebar} from './contexts/navbarMobileSidebar';
export {
useNavbarSecondaryMenu,
NavbarSecondaryMenuFiller,
type NavbarSecondaryMenuComponent,
} from './contexts/navbarSecondaryMenu';
} from './contexts/navbarSecondaryMenu/content';
export {useNavbarSecondaryMenu} from './contexts/navbarSecondaryMenu/display';
export {useBackToTopButton} from './hooks/useBackToTopButton';
export {useHideableNavbar} from './hooks/useHideableNavbar';

View file

@ -7,7 +7,8 @@
import React, {type ReactNode} from 'react';
import {NavbarMobileSidebarProvider} from '../contexts/navbarMobileSidebar';
import {NavbarSecondaryMenuProvider} from '../contexts/navbarSecondaryMenu';
import {NavbarSecondaryMenuContentProvider} from '../contexts/navbarSecondaryMenu/content';
import {NavbarSecondaryMenuDisplayProvider} from '../contexts/navbarSecondaryMenu/display';
const DefaultNavItemPosition = 'right';
@ -28,13 +29,17 @@ export function splitNavbarItems<T extends {position?: 'left' | 'right'}>(
}
/**
* Composes the `NavbarMobileSidebarProvider` and `NavbarSecondaryMenuProvider`.
* Because the latter depends on the former, they can't be re-ordered.
* Composes multiple navbar state providers that are mutually dependent and
* hence can't be re-ordered.
*/
export function NavbarProvider({children}: {children: ReactNode}): JSX.Element {
return (
<NavbarSecondaryMenuContentProvider>
<NavbarMobileSidebarProvider>
<NavbarSecondaryMenuProvider>{children}</NavbarSecondaryMenuProvider>
<NavbarSecondaryMenuDisplayProvider>
{children}
</NavbarSecondaryMenuDisplayProvider>
</NavbarMobileSidebarProvider>
</NavbarSecondaryMenuContentProvider>
);
}