mirror of
https://github.com/facebook/docusaurus.git
synced 2025-06-06 12:52:31 +02:00
refactor(theme-{classic,common}): split navbar into smaller components + cleanup + swizzle config (#6895)
This commit is contained in:
parent
ecbe0b26c5
commit
a1d333e96b
22 changed files with 808 additions and 456 deletions
|
@ -71,13 +71,6 @@ export {
|
|||
|
||||
export {default as Details, type DetailsProps} from './components/Details';
|
||||
|
||||
export {
|
||||
MobileSecondaryMenuProvider,
|
||||
MobileSecondaryMenuFiller,
|
||||
useMobileSecondaryMenuRenderer,
|
||||
} from './utils/mobileSecondaryMenu';
|
||||
export type {MobileSecondaryMenuComponent} from './utils/mobileSecondaryMenu';
|
||||
|
||||
export {
|
||||
useDocsPreferredVersion,
|
||||
useDocsPreferredVersionByPluginId,
|
||||
|
@ -151,6 +144,17 @@ export {
|
|||
TabGroupChoiceProvider,
|
||||
} from './utils/tabGroupChoiceUtils';
|
||||
|
||||
export {
|
||||
splitNavbarItems,
|
||||
NavbarProvider,
|
||||
useNavbarMobileSidebar,
|
||||
} from './utils/navbarUtils';
|
||||
export {
|
||||
useNavbarSecondaryMenu,
|
||||
NavbarSecondaryMenuFiller,
|
||||
} from './utils/navbarSecondaryMenuUtils';
|
||||
export type {NavbarSecondaryMenuComponent} from './utils/navbarSecondaryMenuUtils';
|
||||
|
||||
export {default as useHideableNavbar} from './hooks/useHideableNavbar';
|
||||
export {
|
||||
default as useKeyboardNavigation,
|
||||
|
|
|
@ -1,113 +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,
|
||||
type ReactNode,
|
||||
type ComponentType,
|
||||
} from 'react';
|
||||
import {ReactContextError} from './reactUtils';
|
||||
|
||||
/*
|
||||
The idea behind all this is that a specific component must be able to fill a
|
||||
placeholder in the generic layout. The doc page should be able to fill the
|
||||
secondary menu of the main mobile navbar. This permits to reduce coupling
|
||||
between the main layout and the specific page.
|
||||
|
||||
This kind of feature is often called portal/teleport/gateway... 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.
|
||||
*/
|
||||
|
||||
type ExtraProps = {
|
||||
toggleSidebar: () => void;
|
||||
};
|
||||
|
||||
export type MobileSecondaryMenuComponent<Props> = ComponentType<
|
||||
Props & ExtraProps
|
||||
>;
|
||||
|
||||
type State = {
|
||||
component: MobileSecondaryMenuComponent<unknown>;
|
||||
props: unknown;
|
||||
} | null;
|
||||
|
||||
function useContextValue() {
|
||||
return useState<State>(null);
|
||||
}
|
||||
|
||||
type ContextValue = ReturnType<typeof useContextValue>;
|
||||
|
||||
const Context = React.createContext<ContextValue | null>(null);
|
||||
|
||||
export function MobileSecondaryMenuProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<Context.Provider value={useContextValue()}>{children}</Context.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function useMobileSecondaryMenuContext(): ContextValue {
|
||||
const value = useContext(Context);
|
||||
if (value === null) {
|
||||
throw new ReactContextError('MobileSecondaryMenuProvider');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function useMobileSecondaryMenuRenderer(): (
|
||||
extraProps: ExtraProps,
|
||||
) => ReactNode | undefined {
|
||||
const [state] = useMobileSecondaryMenuContext();
|
||||
if (state) {
|
||||
const Comp = state.component;
|
||||
return function render(extraProps) {
|
||||
return <Comp {...state.props} {...extraProps} />;
|
||||
};
|
||||
}
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
function useShallowMemoizedObject<O extends Record<string, unknown>>(obj: O) {
|
||||
return useMemo(
|
||||
() => obj,
|
||||
// Is this safe?
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[...Object.keys(obj), ...Object.values(obj)],
|
||||
);
|
||||
}
|
||||
|
||||
// Fill the secondary menu placeholder with some real content
|
||||
export function MobileSecondaryMenuFiller<
|
||||
Props extends Record<string, unknown>,
|
||||
>({
|
||||
component,
|
||||
props,
|
||||
}: {
|
||||
component: MobileSecondaryMenuComponent<Props & ExtraProps>;
|
||||
props: Props;
|
||||
}): JSX.Element | null {
|
||||
const [, setState] = useMobileSecondaryMenuContext();
|
||||
|
||||
// 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({component, props: memoizedProps});
|
||||
}, [setState, component, memoizedProps]);
|
||||
|
||||
useEffect(() => () => setState(null), [setState]);
|
||||
|
||||
return null;
|
||||
}
|
|
@ -0,0 +1,170 @@
|
|||
/**
|
||||
* 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} from './reactUtils';
|
||||
import {usePrevious} from './usePrevious';
|
||||
import {useNavbarMobileSidebar} from './navbarUtils';
|
||||
|
||||
/*
|
||||
The idea behind all this is that a specific component must be able to fill a
|
||||
placeholder in the generic layout. The doc page should be able to fill the
|
||||
secondary menu of the main mobile navbar. This permits to reduce coupling
|
||||
between the main layout and the specific page.
|
||||
|
||||
This kind of feature is often called portal/teleport/gateway... 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 type NavbarSecondaryMenuComponent<Props> = ComponentType<Props>;
|
||||
|
||||
type State = {
|
||||
shown: boolean;
|
||||
content:
|
||||
| {
|
||||
component: ComponentType<object>;
|
||||
props: object;
|
||||
}
|
||||
| {component: null; props: null};
|
||||
};
|
||||
|
||||
const InitialState: State = {
|
||||
shown: false,
|
||||
content: {component: null, props: null},
|
||||
};
|
||||
|
||||
function useContextValue() {
|
||||
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] as const;
|
||||
}
|
||||
|
||||
type ContextValue = ReturnType<typeof useContextValue>;
|
||||
|
||||
const Context = React.createContext<ContextValue | null>(null);
|
||||
|
||||
export function NavbarSecondaryMenuProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<Context.Provider value={useContextValue()}>{children}</Context.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function useNavbarSecondaryMenuContext(): ContextValue {
|
||||
const value = useContext(Context);
|
||||
if (value === null) {
|
||||
throw new ReactContextError('MobileSecondaryMenuProvider');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function useShallowMemoizedObject<O extends Record<string, unknown>>(obj: O) {
|
||||
return useMemo(
|
||||
() => obj,
|
||||
// Is this safe?
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[...Object.keys(obj), ...Object.values(obj)],
|
||||
);
|
||||
}
|
||||
|
||||
// Fill the secondary menu placeholder with some real content
|
||||
export function NavbarSecondaryMenuFiller<
|
||||
Props extends Record<string, unknown>,
|
||||
>({
|
||||
component,
|
||||
props,
|
||||
}: {
|
||||
component: NavbarSecondaryMenuComponent<Props>;
|
||||
props: Props;
|
||||
}): 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;
|
||||
}
|
||||
|
||||
export function useNavbarSecondaryMenu(): {
|
||||
shown: boolean;
|
||||
hide: () => void;
|
||||
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],
|
||||
);
|
||||
}
|
129
packages/docusaurus-theme-common/src/utils/navbarUtils.tsx
Normal file
129
packages/docusaurus-theme-common/src/utils/navbarUtils.tsx
Normal file
|
@ -0,0 +1,129 @@
|
|||
/**
|
||||
* 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,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import useWindowSize from '../hooks/useWindowSize';
|
||||
import {useHistoryPopHandler} from './historyUtils';
|
||||
import {NavbarSecondaryMenuProvider} from './navbarSecondaryMenuUtils';
|
||||
import {useActivePlugin} from '@docusaurus/plugin-content-docs/client';
|
||||
import {useThemeConfig} from './useThemeConfig';
|
||||
import {ReactContextError} from './reactUtils';
|
||||
|
||||
const DefaultNavItemPosition = 'right';
|
||||
|
||||
// If split links by left/right
|
||||
// if position is unspecified, fallback to right
|
||||
export function splitNavbarItems<T extends {position?: 'left' | 'right'}>(
|
||||
items: T[],
|
||||
): [leftItems: T[], rightItems: T[]] {
|
||||
function isLeft(item: T): boolean {
|
||||
return (item.position ?? DefaultNavItemPosition) === 'left';
|
||||
}
|
||||
|
||||
const leftItems = items.filter(isLeft);
|
||||
const rightItems = items.filter((item) => !isLeft(item));
|
||||
|
||||
return [leftItems, rightItems];
|
||||
}
|
||||
|
||||
type NavbarMobileSidebarContextValue = {
|
||||
disabled: boolean;
|
||||
shouldRender: boolean;
|
||||
toggle: () => void;
|
||||
shown: boolean;
|
||||
};
|
||||
|
||||
const NavbarMobileSidebarContext = React.createContext<
|
||||
NavbarMobileSidebarContextValue | undefined
|
||||
>(undefined);
|
||||
|
||||
// Mobile sidebar can be disabled in case it would lead to an empty sidebar
|
||||
// In this case it's not useful to display a navbar sidebar toggle button
|
||||
function useNavbarMobileSidebarDisabled() {
|
||||
const activeDocPlugin = useActivePlugin();
|
||||
const {items} = useThemeConfig().navbar;
|
||||
return items.length === 0 && !activeDocPlugin;
|
||||
}
|
||||
|
||||
function useNavbarMobileSidebarContextValue(): NavbarMobileSidebarContextValue {
|
||||
const disabled = useNavbarMobileSidebarDisabled();
|
||||
const windowSize = useWindowSize();
|
||||
|
||||
// Mobile sidebar not visible until user interaction: can avoid SSR rendering
|
||||
const shouldRender = !disabled && windowSize === 'mobile'; // || windowSize === 'ssr';
|
||||
|
||||
const [shown, setShown] = useState(false);
|
||||
|
||||
// Close mobile sidebar on navigation pop
|
||||
// Most likely firing when using the Android back button (but not only)
|
||||
useHistoryPopHandler(() => {
|
||||
if (shown) {
|
||||
setShown(false);
|
||||
// Should we prevent the navigation here?
|
||||
// See https://github.com/facebook/docusaurus/pull/5462#issuecomment-911699846
|
||||
return false; // prevent pop navigation
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setShown((s) => !s);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (windowSize === 'desktop') {
|
||||
setShown(false);
|
||||
}
|
||||
}, [windowSize]);
|
||||
|
||||
// Return stable context value
|
||||
return useMemo(
|
||||
() => ({
|
||||
disabled,
|
||||
shouldRender,
|
||||
toggle,
|
||||
shown,
|
||||
}),
|
||||
[disabled, shouldRender, toggle, shown],
|
||||
);
|
||||
}
|
||||
|
||||
function NavbarMobileSidebarProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
const value = useNavbarMobileSidebarContextValue();
|
||||
return (
|
||||
<NavbarMobileSidebarContext.Provider value={value}>
|
||||
{children}
|
||||
</NavbarMobileSidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useNavbarMobileSidebar(): NavbarMobileSidebarContextValue {
|
||||
const context = React.useContext(NavbarMobileSidebarContext);
|
||||
if (context == null) {
|
||||
throw new ReactContextError('NavbarMobileSidebarProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
// Add all Navbar providers at once
|
||||
export function NavbarProvider({children}: {children: ReactNode}): JSX.Element {
|
||||
return (
|
||||
<NavbarMobileSidebarProvider>
|
||||
<NavbarSecondaryMenuProvider>{children}</NavbarSecondaryMenuProvider>
|
||||
</NavbarMobileSidebarProvider>
|
||||
);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue