mirror of
https://github.com/facebook/docusaurus.git
synced 2025-05-23 14:06:59 +02:00
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:
parent
1f77fc93bb
commit
2e79597f83
8 changed files with 260 additions and 188 deletions
|
@ -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} />;
|
||||
}
|
||||
|
|
|
@ -40,7 +40,11 @@
|
|||
}
|
||||
|
||||
@media (max-width: 996px) {
|
||||
.sidebar {
|
||||
.sidebarDesktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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],
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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],
|
||||
);
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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 (
|
||||
<NavbarMobileSidebarProvider>
|
||||
<NavbarSecondaryMenuProvider>{children}</NavbarSecondaryMenuProvider>
|
||||
</NavbarMobileSidebarProvider>
|
||||
<NavbarSecondaryMenuContentProvider>
|
||||
<NavbarMobileSidebarProvider>
|
||||
<NavbarSecondaryMenuDisplayProvider>
|
||||
{children}
|
||||
</NavbarSecondaryMenuDisplayProvider>
|
||||
</NavbarMobileSidebarProvider>
|
||||
</NavbarSecondaryMenuContentProvider>
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue